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:
2026-10-18 23:38:45 +03:00
commit bff6d70b17
107 changed files with 21428 additions and 0 deletions
+619
View File
@@ -0,0 +1,619 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
"""
The protocol library: how scale indicators around the world put a weight on
a serial line, and how to ask for it.
Each protocol turns raw bytes into `Reading`s. `kind` says how the device
talks:
continuous it sends by itself, over and over — only listen
poll it answers a request; `request` is that request
handshake request -> ACK -> `after_ack` -> answer
Only READ-ONLY requests are used. Letters that zero, tare or switch off a
display on some brand (Z, T, R, and P — display on/off on A&D) are never
sent by auto-detection.
`field_tested` marks what has been verified on real hardware; the rest
follows the manufacturers' published formats and is covered by tests with
the sample frames those documents give.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Iterable, Optional
UNIT_ALIASES = {
"kg": "kg", "kgs": "kg", "g": "g", "gr": "g", "t": "t", "ton": "t",
"lb": "lb", "lbs": "lb", "#": "lb", "oz": "oz", "ozt": "ozt", "ct": "ct",
}
GRAMS_PER = {"g": 1.0, "kg": 1000.0, "t": 1_000_000.0, "lb": 453.59237, "oz": 28.349523125,
"ozt": 31.1034768, "ct": 0.2}
def norm_unit(text: Any) -> str:
if text is None:
return ""
raw = text.decode("latin-1") if isinstance(text, (bytes, bytearray)) else str(text)
raw = raw.strip()
return UNIT_ALIASES.get(raw.lower(), raw)
def convert(value: float, from_unit: str, to_unit: str) -> Optional[float]:
"""Convert between mass units; None when either unit is not a mass unit."""
a, b = GRAMS_PER.get(from_unit), GRAMS_PER.get(to_unit)
if a is None or b is None:
return None
return value * a / b
def _num(text: bytes) -> float:
return float(text.replace(b" ", b"").replace(b",", b"."))
def lines(data: bytes) -> list[bytes]:
"""Complete lines: the text between terminators, empty ones dropped."""
parts = re.split(rb"[\r\n\x03]+", data)
return [p.strip(b"\x02\x00 ") for p in parts if p.strip(b"\x02\x00 ")]
@dataclass
class Reading:
value: Optional[float] # None: the frame carries no number (overload, error)
unit: str = ""
stable: Optional[bool] = None # None: the protocol does not say
net: Optional[bool] = None
overload: bool = False
raw: bytes = b""
checksum_ok: Optional[bool] = None
# Other values the frame carries (e.g. the order number on a label).
fields: dict = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
out = {"value": self.value, "unit": self.unit, "stable": self.stable, "net": self.net,
"overload": self.overload, "raw_hex": self.raw.hex(" ").upper(),
"checksum_ok": self.checksum_ok}
if self.fields:
out["fields"] = dict(self.fields)
return out
@dataclass
class Protocol:
id: str
name: str
kind: str = "continuous"
request: bytes = b""
after_ack: bytes = b""
families: str = ""
reference: str = ""
field_tested: bool = False
specificity: int = 2 # 1 generic … 3 strictly framed (wins ties in detection)
default_poll_ms: int = 500
# Readings auto-detection needs to see. A device that only sends when its
# Print key is pressed may send just one.
min_frames: int = 2
params: dict = field(default_factory=dict)
def readings(self, data: bytes) -> list[Reading]: # pragma: no cover - abstract
raise NotImplementedError
def info(self) -> dict[str, Any]:
return {"id": self.id, "name": self.name, "kind": self.kind,
"on_print": self.kind == "print",
"request_hex": self.request.hex(" ").upper(),
"after_ack_hex": self.after_ack.hex(" ").upper(),
"families": self.families, "reference": self.reference,
"field_tested": self.field_tested}
# ---------------------------------------------------------------------------
# Mettler Toledo
# ---------------------------------------------------------------------------
class MtSics(Protocol):
"""MT-SICS: 'SI' -> 'S S 0.550 kg' (S = stable, D = dynamic)."""
_re = re.compile(rb"^S\s+([SD+\-])\s*([-+]?\s*\d+(?:[.,]\d+)?)?\s*([A-Za-z%#]{0,4})")
def readings(self, data: bytes) -> list[Reading]:
out = []
for line in lines(data):
m = self._re.match(line)
if not m:
continue
flag = m.group(1)
if flag in (b"+", b"-"):
out.append(Reading(None, overload=True, raw=line))
elif m.group(2):
out.append(Reading(_num(m.group(2)), norm_unit(m.group(3)),
stable=flag == b"S", raw=line))
return out
class ToledoContinuous(Protocol):
"""
Toledo Continuous (Mettler Toledo standard continuous output, copied by
many other indicators):
STX SWA SWB SWC W W W W W W T T T T T T CR [CHK]
SWA bits 0-2 place the decimal point (X00, X0, X, 0.X ... 0.0000X);
SWB bit0 net, bit1 negative, bit2 over/under range, bit3 motion,
bit4 kg (else lb); SWC bits 0-2 override the unit (1 g, 2 t, 3 oz).
Bit 5 of each status byte is always set. CHK is the two's complement of
the 7-bit sum of STX..CR.
"""
_re = re.compile(rb"\x02([\x20-\x7f])([\x20-\x7f])([\x20-\x7f])([0-9 ]{6})([0-9 ]{6})\r(.?)",
re.S)
_factor = {0: 100, 1: 10, 2: 1, 3: 0.1, 4: 0.01, 5: 0.001, 6: 0.0001, 7: 0.00001}
_decimals = {0: 0, 1: 0, 2: 0, 3: 1, 4: 2, 5: 3, 6: 4, 7: 5}
def readings(self, data: bytes) -> list[Reading]:
out = []
for m in self._re.finditer(data):
swa, swb, swc = m.group(1)[0], m.group(2)[0], m.group(3)[0]
if not (swa & 0x20 and swb & 0x20 and swc & 0x20):
continue
dp = swa & 0x07
value = round(int(m.group(4).replace(b" ", b"0")) * self._factor[dp], self._decimals[dp])
if swb & 0x02:
value = -value
unit = {1: "g", 2: "t", 3: "oz"}.get(swc & 0x07, "kg" if swb & 0x10 else "lb")
frame = data[m.start():m.end(5) + 1]
checksum_ok = None
if m.group(6) and m.group(6) != b"\x02":
checksum_ok = ((-sum(b & 0x7F for b in frame)) & 0x7F) == (m.group(6)[0] & 0x7F)
out.append(Reading(value, unit, stable=not (swb & 0x08), net=bool(swb & 0x01),
overload=bool(swb & 0x04), raw=frame, checksum_ok=checksum_ok))
return out
# ---------------------------------------------------------------------------
# A&D and the many indicators that copy its format
# ---------------------------------------------------------------------------
class AndStandard(Protocol):
"""'ST,+00000.55 kg', 'US,GS,+0012.34kg', 'OL,+9999999 kg' (ST stable, US unstable, OL overload)."""
_re = re.compile(rb"^(ST|US|OL|QT|WT)\s*,\s*(?:(GS|NT|TR|G|N)\s*,\s*)?([-+])?\s*(\d+(?:[.,]\d+)?)?\s*"
rb"([A-Za-z%#]{0,4})")
def readings(self, data: bytes) -> list[Reading]:
out = []
for line in lines(data):
m = self._re.match(line)
if not m:
continue
head = m.group(1)
net = {b"NT": True, b"N": True, b"GS": False, b"G": False}.get(m.group(2))
if head == b"OL" or not m.group(4):
out.append(Reading(None, overload=head == b"OL", net=net, raw=line))
continue
value = _num(m.group(4)) * (-1 if m.group(3) == b"-" else 1)
out.append(Reading(value, norm_unit(m.group(5)), stable=head in (b"ST", b"QT"),
net=net, raw=line))
return out
# ---------------------------------------------------------------------------
# SMA (Scale Manufacturers Association) and NCI 3835
# ---------------------------------------------------------------------------
class Sma(Protocol):
"""
SMA SCP-0499: '<LF>W<CR>' -> '<LF><s><r><n><m><f><weight:10><unit:3><CR>'.
s = Z/O/U/E/I/T/space, r = range, n = G/T/N, m = 'M' in motion.
"""
_re = re.compile(rb"\n([ ZOUEIT])([ 0-9])([GTNgtn])([M ])([ -~])([ -~]{10})([ -~]{1,3})\r")
def readings(self, data: bytes) -> list[Reading]:
out = []
for m in self._re.finditer(data):
field_text = m.group(6).strip()
status = m.group(1)
try:
value = _num(field_text) if re.fullmatch(rb"[-+]?\d+(?:\.\d+)?", field_text) else None
except ValueError:
value = None
out.append(Reading(value, norm_unit(m.group(7)), stable=m.group(4) != b"M",
net=m.group(3) in (b"N", b"n"), overload=status in (b"O", b"U"),
raw=m.group(0)))
return out
class Nci(Protocol):
"""NCI 3835: 'W<CR>' -> '<LF> 1.23lb<CR><LF>hh<CR><ETX>' (hh status, bit0 of h1 = motion)."""
_re = re.compile(rb"\n\s*([-+]?\s*\d+(?:\.\d+)?)\s*(lb|kg|g|oz|LB|KG|G|OZ)\s*\r(?:\n(.)(.)\r)?", re.S)
def readings(self, data: bytes) -> list[Reading]:
out = []
for m in self._re.finditer(data):
stable = None if m.group(3) is None else not (m.group(3)[0] & 0x01)
out.append(Reading(_num(m.group(1)), norm_unit(m.group(2)), stable=stable, raw=m.group(0)))
return out
# ---------------------------------------------------------------------------
# Ohaus, Sartorius
# ---------------------------------------------------------------------------
class Ohaus(Protocol):
"""'IP' -> ' 0.550 kg ' ('?' after the unit when unstable, optional G/N)."""
_re = re.compile(rb"^([-+]?\s*\d+(?:[.,]\d+)?)\s*([A-Za-z%#]{1,4})\s*(\?)?\s*([GNT])?\s*$")
def readings(self, data: bytes) -> list[Reading]:
out = []
for line in lines(data):
m = self._re.match(line)
if m:
out.append(Reading(_num(m.group(1)), norm_unit(m.group(2)), stable=m.group(3) is None,
net={b"N": True, b"G": False}.get(m.group(4)), raw=line))
return out
class Sartorius(Protocol):
"""
Sartorius SBI: 'ESC P' -> '+ 0.550 kg ' or 'N + 0.550 kg'.
An unstable value is sent WITHOUT its unit — that is the stability flag.
"""
_re = re.compile(rb"^(?:([A-Za-z#]{1,6})\s+)?([-+])\s*(\d+(?:[.,]\d+)?)\s*([A-Za-z%]{0,4})\s*$")
def readings(self, data: bytes) -> list[Reading]:
out = []
for line in lines(data):
m = self._re.match(line)
if not m:
continue
value = _num(m.group(3)) * (-1 if m.group(2) == b"-" else 1)
ident = (m.group(1) or b"").upper()
out.append(Reading(value, norm_unit(m.group(4)), stable=bool(m.group(4)),
net=True if ident.startswith(b"N") else (False if ident.startswith(b"G") else None),
raw=line))
return out
# ---------------------------------------------------------------------------
# CAS (ENQ/ACK/DC1)
# ---------------------------------------------------------------------------
class CasEnq(Protocol):
"""ENQ -> ACK, DC1 -> '...S/U ... weight unit...'. Parsed loosely; experimental."""
_re = re.compile(rb"([SU])[ ,A-Z]*?([-+ ])?\s*(\d+(?:\.\d+)?)\s*(kg|g|lb|oz|KG|LB|OZ|G)")
def readings(self, data: bytes) -> list[Reading]:
out = []
for m in self._re.finditer(data):
value = _num(m.group(3)) * (-1 if m.group(2) == b"-" else 1)
out.append(Reading(value, norm_unit(m.group(4)), stable=m.group(1) == b"S", raw=m.group(0)))
return out
# ---------------------------------------------------------------------------
# Chinese XK3190 family (Yaohua and compatibles)
# ---------------------------------------------------------------------------
class Xk3190Continuous(Protocol):
"""
XK3190 continuous mode (tF=0), 12 bytes:
STX, sign, 6 digits (high first), decimals (0-4), XOR high nibble, XOR low nibble, ETX.
XOR covers the sign through the decimals byte. No unit is sent.
"""
_re = re.compile(rb"\x02([+-])(\d{6})([0-4])(..)\x03", re.S)
def readings(self, data: bytes) -> list[Reading]:
out = []
for m in self._re.finditer(data):
body = m.group(1) + m.group(2) + m.group(3)
x = 0
for b in body:
x ^= b
got = m.group(4)
hex_form = f"{x >> 4:X}{x & 0xF:X}".encode()
nibble_form = bytes([0x30 + (x >> 4), 0x30 + (x & 0xF)])
decimals = int(m.group(3))
value = round(int(m.group(2)) / (10 ** decimals), decimals) * (-1 if m.group(1) == b"-" else 1)
out.append(Reading(value, "", raw=m.group(0),
checksum_ok=got.upper() in (hex_form, nibble_form)))
return out
class Xk3190Reversed(Protocol):
"""
XK3190 'D2+' continuous modes (tF=2/3): the displayed weight with its
characters REVERSED, frames separated by '='. 70.15 kg -> '51.07000='.
"""
_token = re.compile(rb"^[-0-9.]{5,10}$")
def readings(self, data: bytes) -> list[Reading]:
if data.count(b"=") < 2:
return []
out = []
for token in data.split(b"=")[1:-1]:
token = token.strip()
if not self._token.match(token) or token.count(b".") > 1:
continue
try:
value = float(token[::-1])
except ValueError:
continue
out.append(Reading(value, "", raw=b"=" + token))
return out
# ---------------------------------------------------------------------------
# Mahak (Iran) — field-tested
# ---------------------------------------------------------------------------
class MahakMds(Protocol):
"""
Mahak MDS series (e.g. MDS 13000, older firmware): request 4D 09 04 07,
reply 58 ?? 07 07 W2 W1 W0 — grams, 24-bit big-endian. The scale's menu
must be set to Serial mode. Byte 1 varies and is ignored.
"""
_re = re.compile(rb"\x58.\x07\x07(...)", re.S)
def readings(self, data: bytes) -> list[Reading]:
return [Reading(float(int.from_bytes(m.group(1), "big")), "g", raw=m.group(0))
for m in self._re.finditer(data)]
# ---------------------------------------------------------------------------
# Label printer streams — indicators wired to a label printer
# ---------------------------------------------------------------------------
_KV = re.compile(r"([A-Za-z][A-Za-z0-9_.]*)\s*:\s*(.*?)(?=\s+[A-Za-z][A-Za-z0-9_.]*\s*:|$)")
_DATE = re.compile(r"\b(\d{2,4}[/.-]\d{1,2}[/.-]\d{1,4})\b(?:\s*-?\s*(\d{1,2}:\d{2}(?::\d{2})?))?")
_NUMBER = re.compile(r"^[-+]?\s*\d+(?:[.,]\d+)?$")
_NUMBER_UNIT = re.compile(r"^([-+]?\s*\d+(?:[.,]\d+)?)\s*\(?\s*(kg|g|t|lb|lbs|oz)\s*\)?$", re.I)
_WEIGHT_WORD = re.compile(r"(weight|wt|net|gross|n\.?w|g\.?w|\u0648\u0632\u0646)\s*[:=]?\s*([-+]?\d+(?:[.,]\d+)?)\s*\(?\s*(kg|g|t|lb|oz)?",
re.I)
def _unit_only(text: str) -> str:
t = text.strip().strip("()[]").strip()
return norm_unit(t) if norm_unit(t) in GRAMS_PER else ""
class LabelPrinter(Protocol):
"""
An indicator that prints labels: on its Print key it sends the whole label
program to a label printer — TSPL/TSPL2 (TSC and compatibles), EPL2 (Zebra/
Eltron, Argox) or ZPL (Zebra). Listening to that line (a Y cable leaves the
printer connected) gives the weight the operator printed, plus whatever
else the label carries: order number, shift, date, thickness …
One reading per printed label (TSPL `PRINT`, EPL `P`, ZPL `^XZ`). The weight
is the number printed next to a weight unit — "141.800" followed by "(Kg)",
or "1.25 kg" in one text — or after a word such as Weight/Net/Gross/وزن.
A printed weight counts as stable: indicators print settled weights.
"""
_tspl_text = re.compile(rb'^(?:TEXT|BLOCK)\s[^"]*"[^"]*"[^"]*?"(.*?)"?\s*$')
_tspl_barcode = re.compile(rb'^BARCODE\s[^"]*"[^"]*"[^"]*?"(.*?)"?\s*$')
_epl_text = re.compile(rb'^A\d+,\d+,[^"]*"(.*?)"?\s*$')
_epl_barcode = re.compile(rb'^B\d+,\d+,[^"]*"(.*?)"?\s*$')
_zpl_field = re.compile(rb"\^FD(.*?)\^FS", re.S)
_end = re.compile(rb"(?:^|[\r\n])(?:PRINT\s+\d+(?:\s*,\s*\d+)?|P\d+(?:,\d+)?)\s*(?=[\r\n]|$)|\^XZ")
def _texts(self, label: bytes) -> tuple[list[str], list[str]]:
texts: list[str] = []
codes: list[str] = []
if b"^FD" in label:
for m in self._zpl_field.finditer(label):
texts.append(m.group(1).decode("latin-1"))
return texts, codes
for line in re.split(rb"[\r\n]+", label):
line = line.strip()
for rx, bucket in ((self._tspl_text, texts), (self._tspl_barcode, codes),
(self._epl_text, texts), (self._epl_barcode, codes)):
m = rx.match(line)
if m:
bucket.append(m.group(1).decode("latin-1"))
break
return texts, codes
def _weight(self, texts: list[str]) -> Optional[tuple[float, str]]:
clean = [t.strip() for t in texts]
for i, t in enumerate(clean):
m = _NUMBER_UNIT.match(t)
if m:
return _num(m.group(1).encode()), norm_unit(m.group(2))
if _NUMBER.match(t):
for j in (i + 1, i - 1):
if 0 <= j < len(clean) and _unit_only(clean[j]):
return _num(t.encode()), _unit_only(clean[j])
for t in clean:
m = _WEIGHT_WORD.search(t)
if m:
return _num(m.group(2).encode()), norm_unit(m.group(3) or "")
return None
@staticmethod
def _fields(texts: list[str], codes: list[str]) -> dict:
fields: dict[str, str] = {}
for t in texts:
d = _DATE.search(t)
if d and "date" not in fields:
fields["date"] = d.group(1)
if d.group(2):
fields["time"] = d.group(2)
continue
for key, value in _KV.findall(t):
value = value.strip().strip('"').strip()
if value:
fields.setdefault(key.strip(), value)
if codes:
fields["barcode"] = codes[0].replace("\t", " | ").strip()
return fields
def readings(self, data: bytes) -> list[Reading]:
out = []
start = 0
for end in self._end.finditer(data):
label = data[start:end.end()]
start = end.end()
texts, codes = self._texts(label)
found = self._weight(texts)
if found is None:
continue
value, unit = found
out.append(Reading(value, unit, stable=True, raw=label.strip(b"\r\n"),
fields=self._fields(texts, codes)))
return out
# ---------------------------------------------------------------------------
# Generic and custom
# ---------------------------------------------------------------------------
class GenericAscii(Protocol):
"""Any readable line with a number in it — the last resort for continuous output."""
_re = re.compile(rb"([-+]?\s*\d+(?:[.,]\d+)?)\s*(kg|g|lb|lbs|oz|t|KG|G|LB|OZ|T)?\b")
_stable_words = re.compile(rb"\b(ST|S)\b")
_unstable_words = re.compile(rb"\b(US|D|M|MOTION)\b|\?")
def readings(self, data: bytes) -> list[Reading]:
out = []
for line in lines(data):
if len(line) > 80 or sum(32 <= b < 127 for b in line) < len(line) * 0.9:
continue
numbers = list(self._re.finditer(line))
if not numbers:
continue
m = numbers[-1]
stable = None
if self._unstable_words.search(line):
stable = False
elif self._stable_words.search(line):
stable = True
out.append(Reading(_num(m.group(1)), norm_unit(m.group(2)), stable=stable, raw=line))
return out
class CustomBinary(Protocol):
"""
A binary frame described by hand: `pattern` (hex, '??' = any byte), then
`value_length` bytes at `value_offset` from the pattern's first byte.
"""
def readings(self, data: bytes) -> list[Reading]:
p = self.params
pattern = [None if t == "??" else int(t, 16) for t in str(p.get("pattern") or "").split()]
offset = int(p.get("value_offset") or 0)
length = int(p.get("value_length") or 1)
order = "little" if p.get("byte_order") == "little" else "big"
signed = bool(p.get("signed"))
unit = norm_unit(p.get("unit") or "")
out = []
n = len(pattern)
starts = [0] if not n else [
i for i in range(0, len(data) - n + 1)
if all(b is None or data[i + k] == b for k, b in enumerate(pattern))]
for start in starts:
end = start + offset + length
if end > len(data):
continue
value = int.from_bytes(data[start + offset:end], order, signed=signed)
out.append(Reading(float(value), unit, raw=data[start:max(end, start + n)]))
return out
class CustomAscii(Protocol):
"""Any text format described by hand: the first group of `regex` is the number."""
def readings(self, data: bytes) -> list[Reading]:
regex = str(self.params.get("regex") or "")
if not regex:
return []
compiled = re.compile(regex.encode("latin-1"))
unit = norm_unit(self.params.get("unit") or "")
out = []
for line in lines(data):
m = compiled.search(line)
if m and m.groups() and m.group(1):
try:
out.append(Reading(_num(m.group(1)), unit, raw=line))
except ValueError:
continue
return out
# ---------------------------------------------------------------------------
# The catalogue
# ---------------------------------------------------------------------------
def _catalogue() -> dict[str, Protocol]:
items: Iterable[Protocol] = [
MtSics("mt_sics", "Mettler Toledo MT-SICS", kind="poll", request=b"SI\r\n", specificity=3,
families="Mettler Toledo terminals and balances (IND, ICS, XS, ML…)",
reference="MT-SICS reference manual"),
ToledoContinuous("toledo_cont", "Toledo Continuous (STX + status words)", specificity=3,
families="Mettler Toledo IND/Panther/Jaguar/Lynx and many compatible indicators",
reference="Mettler Toledo 'Standard continuous output' (e.g. IND425 manual 4.2)"),
AndStandard("and_std", "A&D standard format (ST/US/OL)", kind="poll", request=b"Q\r\n",
specificity=3,
families="A&D indicators/balances and many Asian indicators (continuous or on request)",
reference="A&D indicator manuals, data format section"),
Sma("sma", "SMA standard (Scale Manufacturers Association)", kind="poll", request=b"\nW\r",
specificity=3, families="Rice Lake, Avery Weigh-Tronix, Cardinal, Fairbanks and other SMA-compliant indicators",
reference="SMA SCP-0499"),
Nci("nci", "NCI 3835 / Weigh-Tronix", kind="poll", request=b"W\r", specificity=2,
families="NCI / Avery Weigh-Tronix shipping and bench scales",
reference="NCI 3835 protocol"),
Ohaus("ohaus", "Ohaus", kind="poll", request=b"IP\r\n", specificity=2,
families="Ohaus Defender, Ranger, Valor and compatible indicators",
reference="Ohaus interface command table (IP = immediate print)"),
Sartorius("sartorius", "Sartorius SBI", kind="poll", request=b"\x1bP\r\n", specificity=2,
families="Sartorius balances and Combics/Signum indicators",
reference="Sartorius SBI data output format"),
CasEnq("cas_enq", "CAS (ENQ/ACK/DC1), experimental", kind="handshake", request=b"\x05",
after_ack=b"\x11", specificity=2, families="CAS scales and compatibles",
reference="CAS ENQ/ACK polling"),
Xk3190Continuous("xk3190_tf0", "XK3190 continuous (tF=0)", specificity=3,
families="Yaohua XK3190-A9/A12 and compatible Chinese indicators",
reference="XK3190-A9+ user manual, serial output"),
Xk3190Reversed("xk3190_reversed", "XK3190 '=' reversed-digit stream (tF=2/3)", specificity=2,
field_tested=True,
families="Yaohua XK3190 series (A9, A12, T7E / YH-T7E …) and compatible indicators/big displays",
reference="XK3190-A9+ user manual (D2+ continuous mode); YH-T7E user manual V1.01, 3.3"),
MahakMds("mahak_mds", "Mahak MDS binary (4D 09 04 07)", kind="poll",
request=bytes.fromhex("4D090407"), specificity=3, field_tested=True,
families="Mahak MDS 13000 and related Mahak scales (Iran), Serial mode",
reference="Mahak developer guide + field test"),
LabelPrinter("label_print", "Label printer stream (TSPL / EPL / ZPL)", kind="print",
specificity=3, min_frames=1, field_tested=True,
families="Indicators wired to a label printer (TSC, Zebra, Argox…); sends on Print. "
"Tested on a PTC400 indicator (TSPL)",
reference="TSC TSPL/TSPL2, Zebra EPL2 and ZPL programming manuals + field test"),
GenericAscii("ascii_generic", "Generic text (number + unit)", specificity=1,
families="Any indicator that streams readable weight text",
reference="heuristic"),
CustomBinary("custom_binary", "Custom binary frame", kind="poll", specificity=0,
reference="configured by hand"),
CustomAscii("custom_ascii", "Custom text (regular expression)", kind="poll", specificity=0,
reference="configured by hand"),
]
return {p.id: p for p in items}
PROTOCOLS: dict[str, Protocol] = _catalogue()
# Detection order. Continuous formats are tried on whatever a port sends by
# itself; poll protocols are asked in turn when it sends nothing.
CONTINUOUS_IDS = ["label_print", "toledo_cont", "xk3190_tf0", "and_std", "mt_sics", "sma",
"xk3190_reversed", "ohaus", "sartorius", "ascii_generic"]
POLL_IDS = ["mt_sics", "and_std", "sma", "nci", "ohaus", "sartorius", "mahak_mds", "cas_enq"]
def get_protocol(protocol_id: str, params: Optional[dict] = None) -> Protocol:
base = PROTOCOLS.get(protocol_id)
if base is None:
raise KeyError(f"unknown protocol: {protocol_id}")
if params:
clone = type(base)(**{**base.__dict__, "params": dict(params)})
return clone
return base
def catalogue_info() -> list[dict[str, Any]]:
return [p.info() for p in PROTOCOLS.values()]