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,237 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
Profiles: a saved answer to "how does the scale on this port talk".
|
||||
|
||||
A profile is what auto-detection found (or what was set by hand): the port,
|
||||
the line settings, the request, and the protocol that reads the reply. The
|
||||
bridge keeps profiles in bridge.json; anybody on the network with the access
|
||||
key can then ask `/weight?profile=<name>` and get a finished number.
|
||||
|
||||
{
|
||||
"name": "packing-1", letters, digits, - and _
|
||||
"label": "Packing line scale",
|
||||
"port": "COM7",
|
||||
"protocol": "mahak_mds",
|
||||
"params": {}, only for custom_binary / custom_ascii
|
||||
"serial": {...}, LineSettings.serial_dict()
|
||||
"exchange": {...}, LineSettings.exchange_dict()
|
||||
"unit": "", the unit when the frames carry none
|
||||
"output_unit": "", convert to this unit ("" = as sent)
|
||||
"stable_samples": 3 equal readings in a row = stable, when
|
||||
the protocol has no stability flag
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from .protocols import GRAMS_PER, PROTOCOLS, Reading, convert, get_protocol, norm_unit
|
||||
from .serialio import LineSettings, PortWorker
|
||||
|
||||
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$")
|
||||
MAX_PROFILES = 64
|
||||
RECENT_CHUNKS = 24
|
||||
TAIL_BYTES = 64 # end of the previous chunk read together with the next
|
||||
|
||||
|
||||
def slug(text: str) -> str:
|
||||
s = re.sub(r"[^A-Za-z0-9_-]+", "-", str(text or "")).strip("-_").lower()
|
||||
return (s or "scale")[:40]
|
||||
|
||||
|
||||
def validate(raw: dict) -> dict:
|
||||
"""Check a profile that arrives from the user interface or from bridge.json."""
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("profile must be an object")
|
||||
name = str(raw.get("name") or "").strip()
|
||||
if not NAME_RE.match(name):
|
||||
raise ValueError("profile name: 1-40 letters, digits, '-' or '_', starting with a letter or digit")
|
||||
protocol = str(raw.get("protocol") or "")
|
||||
if protocol not in PROTOCOLS:
|
||||
raise ValueError(f"unknown protocol: {protocol!r}")
|
||||
params = raw.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
raise ValueError("params must be an object")
|
||||
if protocol == "custom_ascii":
|
||||
try:
|
||||
re.compile(str(params.get("regex") or "").encode("latin-1"))
|
||||
except (re.error, UnicodeEncodeError) as e:
|
||||
raise ValueError(f"invalid regular expression: {e}") from e
|
||||
if not params.get("regex"):
|
||||
raise ValueError("custom_ascii needs a regular expression")
|
||||
serial_cfg = dict(raw.get("serial") or {})
|
||||
port = str(raw.get("port") or serial_cfg.get("port") or "")
|
||||
line = LineSettings.from_dicts(serial_cfg, raw.get("exchange") or {}, port=port)
|
||||
unit = norm_unit(raw.get("unit") or "")
|
||||
output_unit = norm_unit(raw.get("output_unit") or "")
|
||||
if output_unit and output_unit not in GRAMS_PER:
|
||||
raise ValueError(f"unknown unit: {output_unit!r}")
|
||||
stable_samples = min(max(int(raw.get("stable_samples") or 3), 1), 20)
|
||||
return {
|
||||
"name": name,
|
||||
"label": str(raw.get("label") or "")[:80],
|
||||
"port": line.port,
|
||||
"protocol": protocol,
|
||||
"params": {k: params[k] for k in ("pattern", "value_offset", "value_length", "byte_order",
|
||||
"signed", "regex", "unit") if k in params},
|
||||
"serial": line.serial_dict(),
|
||||
"exchange": line.exchange_dict(),
|
||||
"unit": unit,
|
||||
"output_unit": output_unit,
|
||||
"stable_samples": stable_samples,
|
||||
"created": str(raw.get("created") or datetime.now().astimezone().isoformat(timespec="seconds")),
|
||||
}
|
||||
|
||||
|
||||
def from_detection(result: dict, name: str, label: str = "") -> dict:
|
||||
return validate({
|
||||
"name": name,
|
||||
"label": label,
|
||||
"port": result["port"],
|
||||
"protocol": result["protocol"],
|
||||
"serial": result["serial"],
|
||||
"exchange": result["exchange"],
|
||||
"unit": "" if (result.get("reading") or {}).get("unit") else "kg",
|
||||
})
|
||||
|
||||
|
||||
def line_settings(profile: dict) -> LineSettings:
|
||||
return LineSettings.from_dicts(profile["serial"], profile.get("exchange") or {},
|
||||
port=profile["port"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Turning the bytes a worker holds into one answer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Timed:
|
||||
t_ms: int
|
||||
reading: Reading
|
||||
|
||||
|
||||
def timed_readings(protocol_id: str, params: Optional[dict],
|
||||
chunks: list[tuple[int, bytes]]) -> list[Timed]:
|
||||
proto = get_protocol(protocol_id, params)
|
||||
out: list[Timed] = []
|
||||
if proto.kind == "print":
|
||||
# A label can arrive in more than one chunk: read them joined, and
|
||||
# date each reading by the chunk that completed it.
|
||||
buf = b""
|
||||
seen = 0
|
||||
for t, data in chunks:
|
||||
buf += data
|
||||
found = proto.readings(buf)
|
||||
out.extend(Timed(t, r) for r in found[seen:])
|
||||
seen = len(found)
|
||||
return out
|
||||
# Some formats only close a frame with the start of the next one (XK3190's
|
||||
# "=…=…" stream), and a device that pauses between frames puts each frame in
|
||||
# its own chunk. So read every chunk together with the end of the previous
|
||||
# one, and keep only the readings that this chunk added.
|
||||
tail = b""
|
||||
for t, data in chunks:
|
||||
before = len(proto.readings(tail)) if tail else 0
|
||||
out.extend(Timed(t, r) for r in proto.readings(tail + data)[before:])
|
||||
tail = (tail + data)[-TAIL_BYTES:]
|
||||
return out
|
||||
|
||||
|
||||
def evaluate(profile: dict, worker: PortWorker, max_age_ms: int, now: int) -> dict[str, Any]:
|
||||
"""The newest reading, with its stability and unit settled."""
|
||||
readings = timed_readings(profile["protocol"], profile.get("params"), worker.recent(RECENT_CHUNKS))
|
||||
printing = is_on_print(profile)
|
||||
base = {"ok": False, "profile": profile.get("name"), "port": profile["port"],
|
||||
"protocol": profile["protocol"], "open": worker.is_open, "on_print": printing}
|
||||
if not readings:
|
||||
if not worker.is_open:
|
||||
return {**base, "error": worker.last_error or "port is not open"}
|
||||
if printing:
|
||||
return {**base, "error": "waiting for the Print key on the scale"}
|
||||
return {**base, "error": "no data from the scale yet" if not worker.recent(1) else
|
||||
"data arrives but no weight could be read from it — check the protocol"}
|
||||
latest = readings[-1]
|
||||
age = now - latest.t_ms
|
||||
reading = latest.reading
|
||||
stale = age > max_age_ms
|
||||
if reading.value is None:
|
||||
return {**base, "ok": True, "value": None, "unit": reading.unit, "stable": False,
|
||||
"overload": reading.overload, "age_ms": age, "stale": stale,
|
||||
"raw_hex": reading.raw.hex(" ").upper()}
|
||||
|
||||
stable = reading.stable
|
||||
if stable is None:
|
||||
# No stability flag in this protocol: the same value several times in
|
||||
# a row, all of them recent, counts as stable.
|
||||
need = int(profile.get("stable_samples") or 3)
|
||||
poll_ms = int((profile.get("exchange") or {}).get("poll_interval_ms") or 500)
|
||||
window = max_age_ms + need * max(poll_ms, 250) * 2
|
||||
tail = [x.reading.value for x in readings[-need:] if now - x.t_ms <= window]
|
||||
stable = len(tail) >= need and all(v == tail[-1] for v in tail)
|
||||
unit = reading.unit or profile.get("unit") or ""
|
||||
value = reading.value
|
||||
output_unit = profile.get("output_unit") or ""
|
||||
if output_unit and unit and output_unit != unit:
|
||||
converted = convert(value, unit, output_unit)
|
||||
if converted is not None:
|
||||
value, unit = round(converted, 6), output_unit
|
||||
measured = datetime.now().astimezone().timestamp() - age / 1000
|
||||
return {
|
||||
**base,
|
||||
"ok": True,
|
||||
"value": value,
|
||||
"unit": unit,
|
||||
"stable": bool(stable) and not stale,
|
||||
"net": reading.net,
|
||||
"overload": reading.overload,
|
||||
"age_ms": age,
|
||||
"stale": stale,
|
||||
"measured_at": datetime.fromtimestamp(measured).astimezone().isoformat(timespec="milliseconds"),
|
||||
"raw_hex": reading.raw.hex(" ").upper(),
|
||||
"checksum_ok": reading.checksum_ok,
|
||||
# e.g. order number, shift and date printed on a label
|
||||
"fields": dict(reading.fields or {}),
|
||||
}
|
||||
|
||||
|
||||
def ad_hoc(port: str, protocol: str, serial_cfg: Optional[dict] = None,
|
||||
exchange_cfg: Optional[dict] = None, params: Optional[dict] = None) -> dict:
|
||||
"""A throw-away profile for `/weight?port=...` without a saved profile."""
|
||||
proto = PROTOCOLS.get(protocol)
|
||||
if proto is None:
|
||||
raise ValueError(f"unknown protocol: {protocol!r}")
|
||||
exchange = dict(exchange_cfg or {})
|
||||
if "request_hex" not in exchange:
|
||||
exchange["request_hex"] = proto.request.hex(" ")
|
||||
exchange["after_ack_hex"] = proto.after_ack.hex(" ")
|
||||
exchange.setdefault("poll_interval_ms", proto.default_poll_ms)
|
||||
return validate({"name": "adhoc", "port": port, "protocol": protocol, "params": params or {},
|
||||
"serial": serial_cfg or {}, "exchange": exchange})
|
||||
|
||||
|
||||
def is_on_print(profile: dict) -> bool:
|
||||
proto = PROTOCOLS.get(profile.get("protocol") or "")
|
||||
return bool(proto and proto.kind == "print")
|
||||
|
||||
|
||||
def wait_for_reading(profile: dict, worker: PortWorker, max_age_ms: int, wait_ms: int,
|
||||
now_fn) -> dict[str, Any]:
|
||||
"""Wait (up to wait_ms) until the newest reading is fresh, then evaluate."""
|
||||
if is_on_print(profile):
|
||||
# Nothing comes until somebody presses Print: do not hold the caller.
|
||||
wait_ms = min(wait_ms, 400)
|
||||
deadline = time.monotonic() + wait_ms / 1000
|
||||
while True:
|
||||
worker.wait_fresh(max_age_ms, int(max(0.0, deadline - time.monotonic()) * 1000))
|
||||
result = evaluate(profile, worker, max_age_ms, now_fn())
|
||||
if result.get("ok") and not result.get("stale"):
|
||||
return result
|
||||
if time.monotonic() >= deadline or (not worker.is_open and worker.last_error):
|
||||
return result
|
||||
# Data arrived but held no reading (a partial frame, a boot message …).
|
||||
time.sleep(0.05)
|
||||
Reference in New Issue
Block a user