Files
cantarire-etichetare/vendor/syncpulse-scale-bridge/scale_bridge/detect.py
T
ske087 bff6d70b17 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
2026-10-18 23:38:45 +03:00

340 lines
15 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
"""
Auto-detection: find how the scale on a COM port talks.
1. LISTEN — at each common speed, in 8N1, for a moment. A scale that sends
by itself shows up here. Readable text means the right speed; bytes that
only become readable with their top bit cleared mean 7 data bits with
parity (7E1/7O1), and the port is listened to again that way. Whatever
arrives is matched against every continuous format in the library.
2. WAIT FOR PRINT (optional) — some indicators send only when their Print
key is pressed (e.g. those wired to a label printer). The port is held
open at one speed until something arrives; garbage means another speed,
so the next press is awaited at the next one. Parity is read from the
bytes themselves, so one press is enough for 7E1/7O1 too.
3. ASK — if nothing useful was heard, each read-only request in the library
is sent at each speed and framing, and the reply is matched against the
protocol that was asked. A reply is confirmed by asking a second time.
`listen_only` skips this step: then nothing at all is sent to the device.
Everything is reported step by step through `log(code, **details)`, so the
user interface can show (and translate) what is happening.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any, Callable, Optional
from .protocols import CONTINUOUS_IDS, POLL_IDS, PROTOCOLS, Reading
from .serialio import LineSettings, Opener, format_hex, printable
PASSIVE_BAUDS = [9600, 2400, 4800, 19200, 1200, 38400, 57600, 115200]
ACTIVE_QUICK = [(9600, "8N1"), (9600, "7E1"), (2400, "7E1"), (2400, "8N1"), (4800, "8N1"),
(19200, "8N1"), (1200, "7E1"), (115200, "8N1")]
ACTIVE_THOROUGH = [(9600, "8N1"), (9600, "7E1"), (9600, "7O1"), (2400, "8N1"), (2400, "7E1"),
(4800, "8N1"), (4800, "7E1"), (19200, "8N1"), (19200, "7E1"), (1200, "8N1"),
(1200, "7E1"), (38400, "8N1"), (57600, "8N1"), (115200, "8N1")]
FRAMING_CHARS = {0x02, 0x03, 0x06, 0x0A, 0x0D, 0x09, 0x01, 0x04}
@dataclass
class Timings:
passive_s: float = 1.3
probe_s: float = 0.45
confirm_s: float = 1.2
drain_s: float = 0.06
def readability(data: bytes, mask7: bool = False) -> float:
if not data:
return 0.0
ok = 0
for b in data:
if mask7:
b &= 0x7F
if 0x20 <= b <= 0x7E or b in FRAMING_CHARS:
ok += 1
return ok / len(data)
def _confidence(protocol_id: str, readings: list[Reading]) -> float:
spec = PROTOCOLS[protocol_id].specificity
base = {3: 0.9, 2: 0.8, 1: 0.6}.get(spec, 0.5)
if any(r.checksum_ok for r in readings):
base += 0.08
if len(readings) >= 3:
base += 0.02
return round(min(base, 0.99), 2)
class Detector:
def __init__(self, opener: Opener, log: Callable[..., None] = lambda code, **kw: None,
cancelled: Callable[[], bool] = lambda: False, thorough: bool = False,
timings: Optional[Timings] = None, print_wait_s: float = 0,
listen_only: bool = False):
self.opener = opener
self.print_wait_s = max(0.0, float(print_wait_s or 0))
self.listen_only = listen_only
self.log = log
self.cancelled = cancelled
self.thorough = thorough
self.t = timings or Timings()
self.active_combos = ACTIVE_THOROUGH if thorough else ACTIVE_QUICK
self.steps_done = 0
# -- plumbing ----------------------------------------------------------
def _step(self) -> None:
self.steps_done += 1
@property
def steps_per_port(self) -> int:
return len(PASSIVE_BAUDS) + len(self.active_combos) * len(POLL_IDS)
@property
def progress(self) -> float:
"""How far the port being searched is, 0..1."""
return min(1.0, self.steps_done / self.steps_per_port)
def _drain(self, ser: Any, seconds: float) -> bytes:
buf = bytearray()
end = time.monotonic() + seconds
while time.monotonic() < end:
waiting = ser.in_waiting
buf += ser.read(waiting if waiting else 1)
return bytes(buf)
def _listen(self, settings: LineSettings, seconds: float) -> bytes:
ser = self.opener(settings)
try:
return self._drain(ser, seconds)
finally:
ser.close()
# -- the two phases ----------------------------------------------------
def _match_continuous(self, data: bytes) -> Optional[tuple[str, list[Reading]]]:
best = None
for pid in CONTINUOUS_IDS:
readings = PROTOCOLS[pid].readings(data)
numeric = [r for r in readings if r.value is not None or r.overload]
if len(numeric) < max(1, PROTOCOLS[pid].min_frames):
continue
key = (PROTOCOLS[pid].specificity, len(numeric))
if best is None or key > best[0]:
best = (key, pid, numeric)
return (best[1], best[2]) if best else None
def _passive(self, port: str) -> tuple[Optional[dict], Optional[dict]]:
"""Returns (result, unknown) — `unknown` describes readable data nobody recognised."""
unknown = None
for baud in PASSIVE_BAUDS:
if self.cancelled():
return None, unknown
settings = LineSettings(port, baud)
self.log("listen", port=port, baud=baud, framing="8N1")
try:
data = self._listen(settings, self.t.passive_s)
except Exception as e: # noqa: BLE001
self._step()
self.log("open_failed", port=port, error=str(e))
return {"error": str(e)}, None
self._step()
if not data:
continue
r8, r7 = readability(data), readability(data, mask7=True)
self.log("heard", port=port, baud=baud, bytes=len(data), readable=round(r8, 2),
readable7=round(r7, 2))
attempts = []
if r8 >= 0.85:
attempts.append(("8N1", data))
elif r7 >= 0.9:
self.log("seven_bit", port=port, baud=baud)
for framing in ("7E1", "7O1"):
try:
seven = self._listen(settings.with_line(baud, framing), self.t.passive_s)
except Exception: # noqa: BLE001
continue
attempts.append((framing, seven))
break
else:
# Framed binary formats can still be found in poorly readable data.
attempts.append(("8N1", data))
for framing, sample in attempts:
match = self._match_continuous(sample)
if match:
pid, readings = match
return self._result(port, pid, settings.with_line(baud, framing), readings,
sample, mode="continuous"), None
if readability(sample) >= 0.85 and unknown is None:
unknown = {"baud": baud, "framing": framing, "sample_hex": format_hex(sample[:96]),
"sample_text": printable(sample[:96])}
return None, unknown
def _until_quiet(self, settings: LineSettings, wait_s: float, quiet_s: float = 0.8) -> bytes:
"""Hold the port open until data arrives (or wait_s passes), then until it goes quiet."""
ser = self.opener(settings)
try:
buf = bytearray()
deadline = time.monotonic() + wait_s
last = 0.0
while not self.cancelled():
waiting = ser.in_waiting
chunk = ser.read(waiting if waiting else 1)
now = time.monotonic()
if chunk:
buf += chunk
last = now
elif buf and now - last >= quiet_s:
break
elif not buf and now >= deadline:
break
return bytes(buf)
finally:
ser.close()
@staticmethod
def _seven_bit(data: bytes) -> tuple[str, bytes]:
"""7 data bits read as 8: bit 7 is the parity bit, which tells even from odd."""
even = sum(1 for b in data if (bin(b & 0x7F).count("1") + (b >> 7)) % 2 == 0)
framing = "7E1" if even >= len(data) / 2 else "7O1"
return framing, bytes(b & 0x7F for b in data)
def _await_print(self, port: str) -> tuple[Optional[dict], Optional[dict]]:
unknown = None
for baud in PASSIVE_BAUDS:
if self.cancelled():
break
settings = LineSettings(port, baud)
self.log("press_print", port=port, baud=baud, seconds=int(self.print_wait_s))
try:
data = self._until_quiet(settings, self.print_wait_s)
except Exception as e: # noqa: BLE001
self.log("open_failed", port=port, error=str(e))
return {"error": str(e)}, None
if not data:
if not self.cancelled():
self.log("print_timeout", port=port)
break
r8, r7 = readability(data), readability(data, mask7=True)
self.log("heard", port=port, baud=baud, bytes=len(data), readable=round(r8, 2),
readable7=round(r7, 2))
framing, sample = "8N1", data
if r8 < 0.85 and r7 >= 0.9:
framing, sample = self._seven_bit(data)
match = self._match_continuous(sample)
if match:
pid, readings = match
return self._result(port, pid, settings.with_line(baud, framing), readings, sample,
mode="print"), None
if readability(sample) >= 0.85:
unknown = {"baud": baud, "framing": framing, "sample_hex": format_hex(sample[:96]),
"sample_text": printable(sample[:96])}
break
self.log("print_wrong_speed", port=port, baud=baud)
return None, unknown
def _active(self, port: str) -> tuple[Optional[dict], Optional[dict]]:
unknown = None
for baud, framing in self.active_combos:
if self.cancelled():
return None, unknown
settings = LineSettings(port).with_line(baud, framing)
try:
ser = self.opener(settings)
except Exception as e: # noqa: BLE001
self.log("open_failed", port=port, error=str(e))
return {"error": str(e)}, None
try:
for pid in POLL_IDS:
if self.cancelled():
return None, unknown
proto = PROTOCOLS[pid]
self.log("ask", port=port, baud=baud, framing=framing, protocol=pid,
request=format_hex(proto.request))
reply = self._ask(ser, proto)
self._step()
if not reply:
continue
readings = [r for r in proto.readings(reply) if r.value is not None or r.overload]
self.log("reply", port=port, protocol=pid, bytes=len(reply),
text=printable(reply[:48]), recognised=bool(readings))
if readings:
again = self._ask(ser, proto, seconds=self.t.confirm_s)
confirm = [r for r in proto.readings(again) if r.value is not None or r.overload]
if confirm:
line = LineSettings(port, baud, request=proto.request,
after_ack=proto.after_ack,
poll_s=proto.default_poll_ms / 1000).with_line(baud, framing)
return self._result(port, pid, line, readings + confirm, reply + again,
mode="poll"), None
elif unknown is None:
unknown = {"baud": baud, "framing": framing, "request": format_hex(proto.request),
"sample_hex": format_hex(reply[:96]), "sample_text": printable(reply[:96])}
finally:
ser.close()
return None, unknown
def _ask(self, ser: Any, proto, seconds: Optional[float] = None) -> bytes:
self._drain(ser, self.t.drain_s)
ser.write(proto.request)
reply = self._drain(ser, seconds or self.t.probe_s)
if proto.after_ack and b"\x06" in reply:
ser.write(proto.after_ack)
reply += self._drain(ser, seconds or self.t.probe_s)
return reply
# -- result ------------------------------------------------------------
def _result(self, port: str, pid: str, settings: LineSettings, readings: list[Reading],
sample: bytes, mode: str) -> dict:
proto = PROTOCOLS[pid]
latest = readings[-1]
self.log("found", port=port, protocol=pid, baud=settings.baudrate, framing=settings.framing,
value=latest.value, unit=latest.unit)
return {
"port": port,
"found": True,
"protocol": pid,
"protocol_name": proto.name,
"field_tested": proto.field_tested,
"mode": mode,
"serial": settings.serial_dict(),
"exchange": settings.exchange_dict(),
"readings": [r.as_dict() for r in readings[-6:]],
"reading": latest.as_dict(),
"sample_hex": format_hex(sample[:96]),
"sample_text": printable(sample[:96]),
"confidence": _confidence(pid, readings),
}
def run_port(self, port: str) -> dict:
self.steps_done = 0
self.log("port_start", port=port)
result, unknown = self._passive(port)
if result and result.get("error"):
return {"port": port, "found": False, "error": result["error"]}
if result is None and self.print_wait_s and not unknown and not self.cancelled():
result, unknown = self._await_print(port)
if result and result.get("error"):
return {"port": port, "found": False, "error": result["error"]}
if result is None and not self.listen_only and not self.cancelled():
result, unknown_active = self._active(port)
if result and result.get("error"):
return {"port": port, "found": False, "error": result["error"]}
unknown = unknown or unknown_active
if result:
return result
self.log("not_found", port=port, heard=bool(unknown))
return {"port": port, "found": False, "unknown": unknown, "cancelled": self.cancelled()}
def run(self, ports: list[str]) -> list[dict]:
results = []
for port in ports:
if self.cancelled():
break
results.append(self.run_port(port))
self.log("done", found=sum(1 for r in results if r.get("found")), cancelled=self.cancelled())
return results