Initial commit: aplicatie de cantarire si etichetare

Aplicatie Flask + SQLite pentru productie alimentara: cantarirea
componentelor unui produs finit si tiparirea etichetei prin QZ Tray.

- produse finite (retete) formate din componente cantarite
- designer vizual de etichete (mm/px, zona sigura, campuri variabile,
  cod QR cu continut din mai multe bucati)
- legare produs finit -> sablon de eticheta
- navigare pe doua taburi: Cantarire / Setari
- administrare: componente, produse finite, sabloane, campuri, istoric
- backup, resetare baza de date, checklist de pregatire, /healthz
- CLI de administrare si serviciu systemd pentru productie
- vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
This commit is contained in:
2026-10-18 23:38:45 +03:00
commit bff6d70b17
107 changed files with 21428 additions and 0 deletions
+431
View File
@@ -0,0 +1,431 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
"""
Serial ports: listing them, describing a line, and keeping one open.
A `PortWorker` owns one COM port. While somebody keeps asking for data it
holds the port open, sends the request (if the device must be asked) at
the poll interval, cuts what comes back into chunks at silences, and keeps
the recent chunks. Unplugging the USB cable, a driver hiccup or a busy port
are all survived: the worker closes, waits, and opens again.
"""
from __future__ import annotations
import logging
import re
import threading
import time
from collections import deque
from dataclasses import dataclass
from typing import Any, Callable, Optional
try:
import serial
from serial.tools import list_ports as _list_ports
except ImportError: # pragma: no cover
serial = None
_list_ports = None
log = logging.getLogger("scale_bridge")
RING_SIZE = 64 # chunks kept per port
MAX_CHUNK = 512 # a continuous stream without silences is cut here
IDLE_CLOSE_S = 120 # a port nobody asked about for this long is released
MAX_REQUEST_BYTES = 64
_PORT_RE = re.compile(r"^(COM\d{1,3}|/dev/[A-Za-z0-9_./-]+)$", re.IGNORECASE)
# USB-serial chips worth a word to the person choosing a port.
KNOWN_CHIPS: dict[int, tuple[str, str]] = {
0x1A86: ("WCH CH340/CH341",
"Clone CH340 chips fail with WCH drivers 3.8 and newer on Windows "
"('A device attached to the system is not functioning'). Install driver "
"3.5.2019.1 or use an FTDI-based adapter."),
0x067B: ("Prolific PL2303",
"Counterfeit PL2303 chips are refused by newer Prolific drivers (Code 10). "
"Use an older driver or an FTDI-based adapter."),
0x0403: ("FTDI", ""),
0x10C4: ("Silicon Labs CP210x", ""),
0x0557: ("ATEN", ""),
0x110A: ("Moxa", ""),
}
def now_ms() -> int:
return int(time.monotonic() * 1000)
def parse_hex(text: str) -> bytes:
"""'4d0904 07', '0x4D 0x09', '4D-09' -> bytes. Raises ValueError."""
cleaned = re.sub(r"0x", "", str(text or ""), flags=re.IGNORECASE)
cleaned = re.sub(r"[\s,;:-]+", "", cleaned)
return bytes.fromhex(cleaned)
def format_hex(data: bytes) -> str:
return data.hex(" ").upper()
def printable(data: bytes) -> str:
names = {0x02: "<STX>", 0x03: "<ETX>", 0x0D: "<CR>", 0x0A: "<LF>", 0x05: "<ENQ>",
0x06: "<ACK>", 0x15: "<NAK>", 0x1B: "<ESC>", 0x11: "<DC1>"}
out = []
for b in data:
if b in names:
out.append(names[b])
elif 32 <= b < 127:
out.append(chr(b))
else:
out.append(f"<{b:02X}>")
return "".join(out)
# ---------------------------------------------------------------------------
# One line's settings
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class LineSettings:
port: str
baudrate: int = 9600
bytesize: int = 8
parity: str = "N"
stopbits: float = 1
dtr: bool = True
rts: bool = True
request: bytes = b"" # sent every poll interval; empty = only listen
after_ack: bytes = b"" # sent when the device answers the request with ACK
poll_s: float = 0.5
gap_s: float = 0.05 # silence that ends a chunk
@property
def framing(self) -> str:
stop = int(self.stopbits) if self.stopbits in (1, 2) else self.stopbits
return f"{self.bytesize}{self.parity}{stop}"
def describe(self) -> str:
req = f", request {format_hex(self.request)}" if self.request else ", listen only"
return f"{self.port} {self.baudrate} {self.framing}{req}"
def with_line(self, baudrate: int, framing: str) -> "LineSettings":
bits, parity, stop = int(framing[0]), framing[1], float(framing[2:])
return LineSettings(self.port, baudrate, bits, parity, stop, self.dtr, self.rts,
self.request, self.after_ack, self.poll_s, self.gap_s)
def serial_dict(self) -> dict[str, Any]:
stop = int(self.stopbits) if self.stopbits in (1, 2) else self.stopbits
return {"port": self.port, "baudrate": self.baudrate, "bytesize": self.bytesize,
"parity": self.parity, "stopbits": stop, "dtr": self.dtr, "rts": self.rts}
def exchange_dict(self) -> dict[str, Any]:
return {"request_hex": format_hex(self.request),
"after_ack_hex": format_hex(self.after_ack),
"poll_interval_ms": int(round(self.poll_s * 1000)),
"frame_gap_ms": int(round(self.gap_s * 1000))}
@classmethod
def from_dicts(cls, serial_cfg: dict, exchange_cfg: Optional[dict] = None,
port: Optional[str] = None) -> "LineSettings":
"""Validate settings that arrive over the network or from a file."""
exchange_cfg = exchange_cfg or {}
name = str(port or serial_cfg.get("port") or "").strip()
if not _PORT_RE.match(name):
raise ValueError(f"invalid port name: {name!r}")
if name.upper().startswith("COM"):
name = name.upper()
baudrate = int(serial_cfg.get("baudrate") or 9600)
if not 50 <= baudrate <= 921600:
raise ValueError(f"invalid baudrate: {baudrate}")
bytesize = int(serial_cfg.get("bytesize") or 8)
if bytesize not in (5, 6, 7, 8):
raise ValueError(f"invalid bytesize: {bytesize}")
parity = str(serial_cfg.get("parity") or "N").upper()[:1]
if parity not in "NEOMS":
raise ValueError(f"invalid parity: {parity}")
stopbits = float(serial_cfg.get("stopbits") or 1)
if stopbits not in (1, 1.5, 2):
raise ValueError(f"invalid stopbits: {stopbits}")
try:
request = parse_hex(exchange_cfg.get("request_hex") or "")
after_ack = parse_hex(exchange_cfg.get("after_ack_hex") or "")
except ValueError as e:
raise ValueError(f"invalid request hex: {e}") from e
if len(request) > MAX_REQUEST_BYTES or len(after_ack) > MAX_REQUEST_BYTES:
raise ValueError("request too long")
poll_ms = min(max(int(exchange_cfg.get("poll_interval_ms") or 500), 100), 10000)
gap_ms = min(max(int(exchange_cfg.get("frame_gap_ms") or 50), 5), 1000)
return cls(port=name, baudrate=baudrate, bytesize=bytesize, parity=parity,
stopbits=int(stopbits) if stopbits in (1, 2) else stopbits,
dtr=bool(serial_cfg.get("dtr", True)), rts=bool(serial_cfg.get("rts", True)),
request=request, after_ack=after_ack,
poll_s=poll_ms / 1000, gap_s=gap_ms / 1000)
# ---------------------------------------------------------------------------
# Opening and listing
# ---------------------------------------------------------------------------
Opener = Callable[[LineSettings], Any]
def open_real(s: LineSettings) -> Any:
if serial is None:
raise RuntimeError("pyserial is not installed")
ser = serial.Serial(port=s.port, baudrate=s.baudrate, bytesize=s.bytesize,
parity=s.parity, stopbits=s.stopbits, timeout=0.02, write_timeout=1.0)
ser.dtr = s.dtr
ser.rts = s.rts
ser.reset_input_buffer()
return ser
def _port_sort_key(name: str) -> tuple:
m = re.match(r"^COM(\d+)$", name, re.IGNORECASE)
return (0, int(m.group(1)), "") if m else (1, 0, name)
def list_serial_ports() -> list[dict[str, Any]]:
if _list_ports is None:
return []
out = []
for p in _list_ports.comports():
chip, note = KNOWN_CHIPS.get(p.vid, (None, "")) if p.vid is not None else (None, "")
text = f"{p.description or ''} {p.hwid or ''}".lower()
kind = "bluetooth" if ("bluetooth" in text or "bthenum" in text) else (
"usb" if p.vid is not None else "native")
out.append({
"device": p.device,
"description": p.description,
"hwid": p.hwid,
"vid": f"{p.vid:04X}" if p.vid is not None else None,
"pid": f"{p.pid:04X}" if p.pid is not None else None,
"manufacturer": p.manufacturer,
"chip": chip,
"note": note,
"kind": kind,
})
return sorted(out, key=lambda p: _port_sort_key(p["device"]))
# ---------------------------------------------------------------------------
# A worker per port
# ---------------------------------------------------------------------------
class PortWorker(threading.Thread):
def __init__(self, settings: LineSettings, opener: Opener):
super().__init__(name=f"port-{settings.port}", daemon=True)
self.settings = settings
self.opener = opener
self.chunks: deque[tuple[int, bytes]] = deque(maxlen=RING_SIZE)
self.cond = threading.Condition()
self.ser: Any = None
self.last_error: Optional[str] = None
self.last_used = time.monotonic()
self.first_attempt = threading.Event()
self._halt = threading.Event()
# Called with (t_ms, bytes) for every chunk, outside the lock.
self.listeners: list[Callable[[int, bytes], None]] = []
self.stats = {"opened_at": None, "bytes_rx": 0, "frames_rx": 0, "requests_tx": 0,
"open_failures": 0}
# -- lifecycle ---------------------------------------------------------
def touch(self) -> None:
self.last_used = time.monotonic()
def stop(self) -> None:
self._halt.set()
@property
def stopped(self) -> bool:
return self._halt.is_set()
def _open(self) -> bool:
try:
self.ser = self.opener(self.settings)
self.last_error = None
self.stats["opened_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
log.info("%s opened", self.settings.describe())
return True
except Exception as e: # noqa: BLE001
self.ser = None
self.stats["open_failures"] += 1
message = f"could not open port {self.settings.port}: {e}"
if message != self.last_error:
log.warning("%s", message)
self.last_error = message
return False
finally:
self.first_attempt.set()
with self.cond:
self.cond.notify_all()
def _close(self) -> None:
if self.ser is not None:
try:
self.ser.close()
except Exception: # noqa: BLE001
pass
self.ser = None
def _push(self, data: bytes, t_ms: int) -> None:
with self.cond:
self.chunks.append((t_ms, data))
self.stats["frames_rx"] += 1
self.cond.notify_all()
for listener in list(self.listeners):
try:
listener(t_ms, data)
except Exception: # noqa: BLE001
log.exception("chunk listener failed")
def run(self) -> None:
s = self.settings
buf = bytearray()
last_rx = 0.0
next_request = 0.0
while not self._halt.is_set():
if time.monotonic() - self.last_used > IDLE_CLOSE_S:
log.info("%s idle for %ds, released", s.port, IDLE_CLOSE_S)
break
if self.ser is None:
if not self._open():
self._halt.wait(2.0)
continue
buf.clear()
next_request = 0.0
try:
now = time.monotonic()
if s.request and now >= next_request:
self.ser.write(s.request)
self.stats["requests_tx"] += 1
next_request = now + s.poll_s
waiting = self.ser.in_waiting
chunk = self.ser.read(waiting if waiting else 1)
now = time.monotonic()
if chunk:
if s.after_ack and b"\x06" in chunk:
self.ser.write(s.after_ack)
buf += chunk
last_rx = now
self.stats["bytes_rx"] += len(chunk)
if len(buf) >= MAX_CHUNK:
self._push(bytes(buf), int(now * 1000))
buf.clear()
continue
if buf and now - last_rx >= s.gap_s:
self._push(bytes(buf), int(last_rx * 1000))
buf.clear()
except Exception as e: # noqa: BLE001
self.last_error = f"{s.port}: {e.__class__.__name__}: {e}"
log.warning("%s lost: %s", s.port, e)
self._close()
self._halt.wait(1.0)
self._close()
self._halt.set()
with self.cond:
self.cond.notify_all()
# -- reading -----------------------------------------------------------
def wait_fresh(self, max_age_ms: int, wait_ms: int) -> None:
"""Return once a chunk younger than max_age_ms exists, or wait_ms passed."""
self.first_attempt.wait(timeout=min(wait_ms, 3000) / 1000)
deadline = time.monotonic() + wait_ms / 1000
with self.cond:
while True:
if self.chunks and now_ms() - self.chunks[-1][0] <= max_age_ms:
return
if self.ser is None and self.last_error:
return
remaining = deadline - time.monotonic()
if remaining <= 0 or self._halt.is_set():
return
self.cond.wait(timeout=remaining)
def recent(self, count: int) -> list[tuple[int, bytes]]:
with self.cond:
return list(self.chunks)[-count:] if count > 0 else []
@property
def is_open(self) -> bool:
return self.ser is not None
def snapshot(self, count: int) -> dict[str, Any]:
chunks = self.recent(count)
is_open = self.is_open
return {
"ok": is_open or bool(chunks and not self.last_error),
"open": is_open,
"error": None if is_open else self.last_error,
"now": now_ms(),
"port": self.settings.port,
"frames": [{"t": t, "hex": format_hex(d)} for t, d in chunks],
"stats": dict(self.stats),
}
class PortBusy(RuntimeError):
"""The port is reserved (auto-detection is running on it)."""
def port_key(port: str) -> str:
return port.upper() if port.upper().startswith("COM") else port
class Registry:
"""At most one worker per port; new settings for a port reopen it."""
def __init__(self, opener: Opener):
self.opener = opener
self.lock = threading.Lock()
self.workers: dict[str, PortWorker] = {}
self.reserved: set[str] = set()
def reserve(self, ports: list[str]) -> None:
"""Close these ports and keep them closed until `unreserve` (for auto-detection)."""
with self.lock:
self.reserved.update(port_key(p) for p in ports)
for p in ports:
self.release(p)
def unreserve(self, ports: list[str]) -> None:
with self.lock:
self.reserved.difference_update(port_key(p) for p in ports)
def worker_for(self, settings: LineSettings) -> PortWorker:
with self.lock:
if port_key(settings.port) in self.reserved:
raise PortBusy(f"{settings.port} is busy: auto-detection is running on it")
current = self.workers.get(settings.port)
if current is not None and (current.stopped or current.settings != settings):
if not current.stopped:
log.info("%s settings changed, reopening", settings.port)
current.stop()
current.join(timeout=3)
current = None
if current is None:
current = PortWorker(settings, self.opener)
self.workers[settings.port] = current
current.start()
current.touch()
return current
def release(self, port: str) -> None:
"""Close a port so something else (auto-detect) can open it."""
with self.lock:
worker = self.workers.pop(port_key(port), None)
if worker is not None:
worker.stop()
worker.join(timeout=3)
def active_ports(self) -> list[str]:
with self.lock:
return [p for p, w in self.workers.items() if not w.stopped]
def stop_all(self) -> None:
with self.lock:
workers = list(self.workers.values())
self.workers.clear()
for w in workers:
w.stop()
for w in workers:
w.join(timeout=3)