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:
@@ -0,0 +1,146 @@
|
||||
# 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")
|
||||
|
||||
Reference in New Issue
Block a user