bff6d70b17
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
432 lines
17 KiB
Python
432 lines
17 KiB
Python
# 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)
|