Files
cantarire-etichetare/vendor/syncpulse-scale-bridge/scale_bridge/labels.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

147 lines
5.7 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani - Cloudtart (https://cloudtart.com)
"""
Scales that send only when Print is pressed — keep listening, keep a log.
Such a scale (typically one wired to a label printer) sends one label per
press and nothing in between, so a label printed while nobody had the port
open would be lost. For every saved profile whose protocol is of that kind,
the bridge keeps the port open all the time and records each label:
in memory the last LABELS_KEPT labels per profile -> GET /labels
on disk labels/<profile>-YYYY-MM-DD.jsonl beside bridge.json
Each record: {"seq", "at", "profile", "port", "value", "unit", "fields", "raw_hex"}.
`seq` grows by one per label since the bridge started, so a reader can ask
for "everything after seq N" and never miss or repeat one.
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
from collections import deque
from datetime import datetime
from typing import Callable, Optional
from .profiles import line_settings
from .protocols import PROTOCOLS, get_protocol
from .serialio import PortBusy, PortWorker, Registry
log = logging.getLogger("scale_bridge")
LABELS_KEPT = 500
PENDING_MAX = 16 * 1024
KEEP_EVERY_S = 15
def on_print(profile: dict) -> bool:
proto = PROTOCOLS.get(profile.get("protocol") or "")
return bool(proto and proto.kind == "print")
class LabelLog:
def __init__(self, folder: str):
self.folder = folder
self.lock = threading.Lock()
self.records: dict[str, deque] = {}
self.pending: dict[str, bytes] = {}
self.seq = 0
# -- feeding -----------------------------------------------------------
def feed(self, profile: dict, data: bytes) -> list[dict]:
"""Bytes as they arrive; a label may come in more than one piece."""
name = profile["name"]
proto = get_protocol(profile["protocol"], profile.get("params"))
with self.lock:
buf = (self.pending.get(name, b"") + data)[-PENDING_MAX:]
readings = proto.readings(buf)
end_rx = getattr(proto, "_end", None)
if readings and end_rx is not None:
ends = list(end_rx.finditer(buf))
buf = buf[ends[-1].end():] if ends else b""
elif readings:
buf = b""
self.pending[name] = buf
out = []
for r in readings:
if r.value is None:
continue
self.seq += 1
rec = {"seq": self.seq, "at": datetime.now().astimezone().isoformat(timespec="seconds"),
"profile": name, "port": profile["port"], "value": r.value,
"unit": r.unit or profile.get("unit") or "", "fields": dict(r.fields or {}),
"raw_hex": r.raw.hex(" ").upper()}
self.records.setdefault(name, deque(maxlen=LABELS_KEPT)).append(rec)
out.append(rec)
for rec in out:
log.info("label %s: %s %s %s", name, rec["value"], rec["unit"], rec["fields"])
self._write(rec)
return out
def _write(self, rec: dict) -> None:
try:
os.makedirs(self.folder, exist_ok=True)
day = rec["at"][:10]
safe = re.sub(r"[^A-Za-z0-9_-]", "_", rec["profile"])
with open(os.path.join(self.folder, f"{safe}-{day}.jsonl"), "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except OSError as e:
log.warning("label log not written: %s", e)
# -- reading -----------------------------------------------------------
def list(self, profile: Optional[str] = None, after: int = 0, limit: int = 100) -> list[dict]:
with self.lock:
names = [profile] if profile else list(self.records)
items = [r for n in names for r in self.records.get(n, ()) if r["seq"] > after]
items.sort(key=lambda r: r["seq"])
return items[-max(1, min(limit, LABELS_KEPT)):]
class Keeper(threading.Thread):
"""Holds the ports of print-only scales open and feeds what they send to the log."""
def __init__(self, registry: Registry, profiles: Callable[[], list[dict]], labels: LabelLog):
super().__init__(name="label-keeper", daemon=True)
self.registry = registry
self.profiles = profiles
self.labels = labels
self._halt = threading.Event()
def stop(self) -> None:
self._halt.set()
def _hook(self, worker: PortWorker, profile: dict) -> None:
if getattr(worker, "label_profile", None) == profile["name"]:
return
worker.listeners.append(lambda t, data, p=profile: self.labels.feed(p, data))
worker.label_profile = profile["name"]
def tick(self) -> None:
for profile in self.profiles():
if not on_print(profile):
continue
try:
worker = self.registry.worker_for(line_settings(profile))
except PortBusy:
continue # auto-detection has the port right now
except Exception as e: # noqa: BLE001
log.warning("cannot keep %s open: %s", profile.get("name"), e)
continue
self._hook(worker, profile)
def run(self) -> None:
while not self._halt.is_set():
try:
self.tick()
except Exception: # noqa: BLE001
log.exception("label keeper")
self._halt.wait(KEEP_EVERY_S)
def labels_folder(config_path: str) -> str:
return os.path.join(os.path.dirname(os.path.abspath(config_path)), "labels")