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
+42
View File
@@ -0,0 +1,42 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
# Additional terms under GPLv3 section 7(b): see NOTICE.md.
"""
SyncPulse Scale Bridge — a universal RS-232 scale reader and network bridge.
Plug a weighing scale's serial cable into a Windows PC, run one program, and
it finds the port, the speed and the protocol by itself, shows the live
weight, and serves it on the local network for any other system to read.
"""
__version__ = "1.1.2"
PRODUCT = "SyncPulse Scale Bridge"
SERVICE_ID = "syncpulse-scale-bridge"
EXE_NAME = "SyncPulseScaleBridge"
# Authorship. Keeping these visible in the user interface is a condition of
# the licence (GPLv3 section 7(b), see NOTICE.md).
AUTHOR = "Farshad Zamani"
COMPANY = "Cloudtart"
WEBSITE = "https://cloudtart.com"
GITHUB = "https://github.com/farshad-zamani"
LINKEDIN = "https://www.linkedin.com/in/farshad-zamani92/"
REPOSITORY = "https://github.com/farshad-zamani/syncpulse-scale-bridge"
PLATFORM = "SyncPulse"
LICENSE_ID = "GPL-3.0-or-later"
COPYRIGHT = f"Copyright (C) 2026 {AUTHOR} - {COMPANY}"
ABOUT = {
"product": PRODUCT,
"version": __version__,
"author": AUTHOR,
"company": COMPANY,
"website": WEBSITE,
"github": GITHUB,
"linkedin": LINKEDIN,
"repository": REPOSITORY,
"platform": PLATFORM,
"license": LICENSE_ID,
"copyright": COPYRIGHT,
}
@@ -0,0 +1,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
import sys
from scale_bridge.cli import main
if __name__ == "__main__":
sys.exit(main())
+418
View File
@@ -0,0 +1,418 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
"""
Command line. Without a command the bridge starts and its page opens in the
browser — that is what a double-click on the program does.
SyncPulseScaleBridge run, open http://127.0.0.1:8765/
SyncPulseScaleBridge ports list COM ports
SyncPulseScaleBridge detect [--port COM7] [--thorough] [--save NAME]
SyncPulseScaleBridge monitor --port COM7 [--baud 9600 --framing 8N1 --request "SI\\r\\n" hex]
SyncPulseScaleBridge weight --profile NAME | --port COM7 --protocol mt_sics
SyncPulseScaleBridge token [--new]
SyncPulseScaleBridge firewall [--remove] [--remote-ip 192.168.1.10]
SyncPulseScaleBridge autostart [--disable]
SyncPulseScaleBridge about
Common options: --config FILE, --simulate SPEC (e.g. mahak_mds,toledo_cont:4800:7E1),
--verbose; for running: --listen-host, --listen-port, --no-browser, --service.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import time
import urllib.request
from logging.handlers import RotatingFileHandler
from typing import Optional
from . import ABOUT, PRODUCT, SERVICE_ID, __version__, profiles, winutil
from .config import Config
from .detect import Detector
from .protocols import PROTOCOLS, catalogue_info
from .serialio import LineSettings, Registry, format_hex, list_serial_ports, now_ms, open_real, parse_hex, printable
from .simulator import SimulatorBank
log = logging.getLogger("scale_bridge")
LOG_TEXT = {
"port_start": "Searching {port} ...",
"listen": " listening at {baud} {framing}",
"heard": " heard {bytes} bytes at {baud} ({readable_pct}% readable)",
"seven_bit": " 7 data bits with parity? listening again as 7E1/7O1",
"reply": " reply to {protocol}: {text}",
"found": " FOUND {protocol} at {baud} {framing}: {value} {unit}",
"not_found": " nothing recognised on {port}",
"open_failed": " cannot open {port}: {error}",
"press_print": " >>> Press PRINT on the scale now (listening at {baud} for {seconds} s)",
"print_wrong_speed": " data at {baud} was not readable - press PRINT again (next speed)",
"print_timeout": " nothing arrived while waiting for PRINT",
}
def say(text: str = "") -> None:
try:
print(text, flush=True)
except Exception: # noqa: BLE001
try:
sys.stdout.buffer.write((text + "\n").encode("ascii", "replace"))
sys.stdout.flush()
except Exception: # noqa: BLE001
pass
def setup_logging(directory: str, verbose: bool, quiet_console: bool = False) -> None:
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
handlers: list[logging.Handler] = []
if not quiet_console and sys.stderr is not None:
console = logging.StreamHandler()
console.setFormatter(fmt)
handlers.append(console)
try:
os.makedirs(directory, exist_ok=True)
file_handler = RotatingFileHandler(os.path.join(directory, "bridge.log"), maxBytes=1_000_000,
backupCount=3, encoding="utf-8")
file_handler.setFormatter(fmt)
handlers.append(file_handler)
except OSError:
pass
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, handlers=handlers or None,
force=True)
def _opener(args):
if args.simulate:
return SimulatorBank(args.simulate).opener(open_real), SimulatorBank(args.simulate)
return open_real, None
def _ports(args) -> list[dict]:
ports = list_serial_ports()
if args.simulate:
ports += SimulatorBank(args.simulate).ports()
return ports
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_ports(args) -> int:
ports = _ports(args)
if not ports:
say("No COM ports found. Plug in the USB-serial adapter and check its driver in Device Manager.")
return 1
for p in ports:
chip = f" [{p['chip']}]" if p.get("chip") else ""
say(f"{p['device']:8} {p.get('description') or ''}{chip} ({p.get('kind')})")
if p.get("note"):
say(f" note: {p['note']}")
return 0
def cmd_detect(args, config: Config) -> int:
opener, _ = _opener(args)
ports = args.ports or [p["device"] for p in _ports(args)
if args.bluetooth or p.get("kind") != "bluetooth"]
if not ports:
say("No COM ports to search.")
return 1
def show(code: str, **kw) -> None:
text = LOG_TEXT.get(code)
if text and (args.verbose or code != "listen"):
kw.setdefault("readable_pct", round(float(kw.get("readable") or 0) * 100))
if "protocol" in kw and kw["protocol"] in PROTOCOLS:
kw["protocol"] = PROTOCOLS[kw["protocol"]].name
try:
say(text.format(**kw))
except (KeyError, IndexError):
say(f" {code} {kw}")
say(f"{PRODUCT} {__version__} - auto-detection ({'thorough' if args.thorough else 'quick'}); "
+ ("listen only: nothing is sent." if args.listen_only else "read-only requests only."))
results = Detector(opener, log=show, thorough=args.thorough, print_wait_s=args.wait_print,
listen_only=args.listen_only).run(ports)
say()
found = [r for r in results if r.get("found")]
for r in results:
if r.get("found"):
s = r["serial"]
reading = r["reading"]
fields = reading.get("fields") or {}
if fields:
say(f"{r['port']}: label fields: " + ", ".join(f"{k}={v}" for k, v in fields.items()))
say(f"{r['port']}: {r['protocol_name']} {s['baudrate']} {s['bytesize']}{s['parity']}{s['stopbits']}"
f" request={r['exchange']['request_hex'] or '-'} weight={reading['value']} {reading['unit']}"
f" confidence={int(r['confidence'] * 100)}%")
else:
unknown = r.get("unknown")
extra = f" (readable data at {unknown['baud']} {unknown['framing']}: {unknown['sample_text'][:60]})" \
if unknown else ""
say(f"{r['port']}: no scale recognised{extra}{' - ' + r['error'] if r.get('error') else ''}")
if args.save and found:
for i, r in enumerate(found):
name = args.save if len(found) == 1 else f"{args.save}-{profiles.slug(r['port'])}"
saved = config.save_profile(profiles.from_detection(r, name))
say(f"saved profile '{saved['name']}' in {config.path}")
if args.json:
say(json.dumps(results, indent=2, ensure_ascii=False))
return 0 if found else 1
def cmd_monitor(args) -> int:
opener, _ = _opener(args)
framing = args.framing.upper()
request = parse_hex(args.request or "")
settings = LineSettings(args.port).with_line(args.baud, framing)
say(f"{settings.port} {settings.baudrate} {settings.framing}, "
f"{'request ' + format_hex(request) + ' every ' + str(args.poll) + ' ms' if request else 'listening'}"
f" for {args.seconds} s (Ctrl+C stops)")
ser = opener(settings)
collected = bytearray()
try:
end = time.monotonic() + args.seconds
next_request = 0.0
buf, last = bytearray(), time.monotonic()
while time.monotonic() < end:
now = time.monotonic()
if request and now >= next_request:
ser.write(request)
next_request = now + args.poll / 1000
waiting = ser.in_waiting
chunk = ser.read(waiting if waiting else 1)
if chunk:
buf += chunk
last = time.monotonic()
continue
if buf and time.monotonic() - last > 0.05:
say(f"{format_hex(bytes(buf))} | {printable(bytes(buf))}")
collected += buf
buf.clear()
except KeyboardInterrupt:
pass
finally:
ser.close()
say()
readable = [(pid, p) for pid, p in PROTOCOLS.items() if not pid.startswith("custom")]
for pid, proto in readable:
found = [r for r in proto.readings(bytes(collected)) if r.value is not None]
if found:
say(f"readable as {proto.name}: {found[-1].value} {found[-1].unit} (x{len(found)})")
return 0
def cmd_weight(args, config: Config) -> int:
opener, _ = _opener(args)
if args.profile:
profile = config.profile(args.profile)
if profile is None:
say(f"no profile named {args.profile!r} in {config.path}")
return 1
elif args.port and args.protocol:
serial_cfg = {"baudrate": args.baud}
if args.framing:
f = args.framing.upper()
serial_cfg.update(bytesize=int(f[0]), parity=f[1], stopbits=int(f[2]))
profile = profiles.ad_hoc(args.port, args.protocol, serial_cfg)
else:
say("give --profile NAME, or --port COMx --protocol ID")
return 2
registry = Registry(opener)
try:
worker = registry.worker_for(profiles.line_settings(profile))
result = profiles.wait_for_reading(profile, worker, 1500, int(args.wait * 1000), now_ms)
finally:
registry.stop_all()
say(json.dumps(result, indent=2, ensure_ascii=False))
return 0 if result.get("ok") else 1
def cmd_token(args, config: Config) -> int:
say(config.rotate_token() if args.new else config.token)
return 0
def cmd_firewall(args, config: Config) -> int:
result = winutil.firewall_close() if args.remove else winutil.firewall_open(config.listen_port, args.remote_ip)
say(json.dumps(result, indent=2))
return 0 if result.get("ok") else 1
def cmd_autostart(args, config: Config) -> int:
result = winutil.autostart_disable() if args.disable else winutil.autostart_enable(config.path)
say(json.dumps(result, indent=2))
return 0 if result.get("ok") else 1
def cmd_about() -> int:
for key in ("product", "version", "author", "company", "website", "github", "linkedin",
"repository", "license"):
say(f"{key:11} {ABOUT[key]}")
say(ABOUT["copyright"])
return 0
def cmd_protocols() -> int:
for p in catalogue_info():
tested = " [field-tested]" if p["field_tested"] else ""
say(f"{p['id']:16} {p['kind']:10} {p['request_hex'] or '-':14} {p['name']}{tested}")
return 0
# ---------------------------------------------------------------------------
# Running the bridge
# ---------------------------------------------------------------------------
def _running_copy(port: int) -> Optional[dict]:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2) as r:
body = json.loads(r.read().decode("utf-8"))
return body if body.get("service") == SERVICE_ID else None
except Exception: # noqa: BLE001
return None
def cmd_run(args, config: Config) -> int:
from .server import Bridge, make_server
host = args.listen_host or config.listen_host
port = int(args.listen_port or config.listen_port)
url = f"http://127.0.0.1:{port}/"
open_page = not (args.no_browser or args.service)
bridge = Bridge(config, simulate=args.simulate or "")
try:
httpd = make_server(bridge, host, port)
except OSError as e:
other = _running_copy(port)
if other:
say(f"{PRODUCT} is already running on this PC (version {other.get('version')}).")
if open_page:
say(f"Opening {url}")
winutil.open_browser(url)
return 0
log.error("cannot listen on %s:%d: %s", host, port, e)
say(f"Port {port} is used by another program. Close it, or start with --listen-port 8766.")
return 1
bar = "=" * 68
say(bar)
say(f" {PRODUCT} {__version__}" + (" [SIMULATION]" if args.simulate else ""))
say(f" {ABOUT['copyright']} - {ABOUT['website']}")
say(f" {ABOUT['license']} - {ABOUT['repository']}")
say(bar)
say(f" Control page (this PC) : {url}")
for ip in winutil.lan_addresses() or ["<this PC's IP address>"]:
say(f" Bridge address : {ip} port {port}")
say(f" Access key : {config.token}")
say(f" Settings : {config.path}")
say(bar)
say(" Keep this window open while the scales are in use; closing it stops the bridge.")
say(" To run in the background at startup, use 'Start with Windows' on the control page.")
say(bar)
log.info("listening on %s:%d", host, port)
if open_page:
winutil.open_browser(url)
try:
httpd.serve_forever(poll_interval=0.5)
except KeyboardInterrupt:
pass
finally:
httpd.server_close()
bridge.registry.stop_all()
return 0
# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
common = argparse.ArgumentParser(add_help=False, argument_default=argparse.SUPPRESS)
common.add_argument("--config", help="settings file (default: bridge.json beside the program)")
common.add_argument("--simulate", metavar="SPEC",
help="add simulated scales on COM90.. e.g. 'mahak_mds,toledo_cont:4800:7E1'")
common.add_argument("--verbose", action="store_true", help="more detail in the log")
ap = argparse.ArgumentParser(
prog="SyncPulseScaleBridge",
description=f"{PRODUCT} {__version__} - finds RS-232 scales, reads them and serves the weight "
f"on the network.",
epilog=f"{ABOUT['copyright']} - {ABOUT['website']} - {ABOUT['repository']}")
ap.add_argument("--version", action="version", version=f"{PRODUCT} {__version__}")
ap.add_argument("--config", default=None)
ap.add_argument("--simulate", default="", metavar="SPEC")
ap.add_argument("--verbose", action="store_true")
ap.add_argument("--listen-host", help="address to listen on (default 0.0.0.0)")
ap.add_argument("--listen-port", type=int, help="port to listen on (default 8765)")
ap.add_argument("--no-browser", action="store_true", help="do not open the control page")
ap.add_argument("--service", action="store_true", help="background mode (used by 'Start with Windows')")
sub = ap.add_subparsers(dest="command", metavar="command")
sub.add_parser("run", parents=[common], help="run the bridge (the default)")
sub.add_parser("ports", parents=[common], help="list COM ports")
d = sub.add_parser("detect", parents=[common], help="find the scale automatically")
d.add_argument("--port", dest="ports", action="append", help="only this port (repeatable)")
d.add_argument("--thorough", action="store_true", help="more speeds and framings (slower)")
d.add_argument("--bluetooth", action="store_true", help="also search Bluetooth serial ports")
d.add_argument("--wait-print", type=float, default=0, metavar="SECONDS",
help="the scale sends only on its Print key: wait this long for a press")
d.add_argument("--listen-only", action="store_true",
help="never send anything to the device (skip asking)")
d.add_argument("--save", metavar="NAME", help="save what is found as a profile")
d.add_argument("--json", action="store_true", help="print the full results as JSON")
m = sub.add_parser("monitor", parents=[common], help="show the raw bytes on a port")
m.add_argument("--port", required=True)
m.add_argument("--baud", type=int, default=9600)
m.add_argument("--framing", default="8N1")
m.add_argument("--request", default="", help="hex bytes to send, e.g. '53 49 0D 0A'")
m.add_argument("--poll", type=int, default=500, help="request interval in ms")
m.add_argument("--seconds", type=float, default=10)
w = sub.add_parser("weight", parents=[common], help="read the weight once")
w.add_argument("--profile")
w.add_argument("--port")
w.add_argument("--protocol", choices=sorted(PROTOCOLS))
w.add_argument("--baud", type=int, default=9600)
w.add_argument("--framing", default="")
w.add_argument("--wait", type=float, default=3.0, help="seconds to wait for a reading")
t = sub.add_parser("token", parents=[common], help="show the access key")
t.add_argument("--new", action="store_true", help="make a new key")
f = sub.add_parser("firewall", parents=[common], help="open the bridge port in Windows Firewall")
f.add_argument("--remove", action="store_true")
f.add_argument("--remote-ip", default=None, help="allow only this address")
a = sub.add_parser("autostart", parents=[common], help="start with Windows (scheduled task)")
a.add_argument("--disable", action="store_true")
sub.add_parser("protocols", parents=[common], help="list the protocol library")
sub.add_parser("about", parents=[common], help="author, licence and links")
return ap
def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(argv)
command = args.command or "run"
if command == "about":
return cmd_about()
if command == "protocols":
return cmd_protocols()
if command == "ports":
return cmd_ports(args)
if command == "monitor":
return cmd_monitor(args)
config = Config(args.config)
setup_logging(os.path.dirname(config.path), args.verbose,
quiet_console=command != "run" and not args.verbose)
if command == "detect":
return cmd_detect(args, config)
if command == "weight":
return cmd_weight(args, config)
if command == "token":
return cmd_token(args, config)
if command == "firewall":
return cmd_firewall(args, config)
if command == "autostart":
return cmd_autostart(args, config)
return cmd_run(args, config)
+195
View File
@@ -0,0 +1,195 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
"""
bridge.json — the bridge's settings, access key and saved scale profiles.
It lives beside the program, so copying the folder copies the setup. When
that folder is read-only (e.g. Program Files), %LOCALAPPDATA% is used.
Files written by earlier versions (1.x) are read as they are.
"""
from __future__ import annotations
import json
import logging
import os
import secrets
import sys
import tempfile
import threading
from typing import Any, Optional
from . import EXE_NAME
from .profiles import MAX_PROFILES, validate
log = logging.getLogger("scale_bridge")
DEFAULT_PORT = 8765
DEFAULTS: dict[str, Any] = {
"listen_host": "0.0.0.0",
"listen_port": DEFAULT_PORT,
"token": "",
# Empty = any machine that has the key. Put the platform server's IP here
# to refuse everybody else, e.g. ["192.168.1.10"].
"allowed_clients": [],
"profiles": [],
}
def app_dir() -> str:
if getattr(sys, "frozen", False):
return os.path.dirname(os.path.abspath(sys.executable))
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _writable(directory: str) -> bool:
try:
fd, probe = tempfile.mkstemp(prefix=".write-test-", dir=directory)
os.close(fd)
os.remove(probe)
return True
except OSError:
return False
def default_path() -> str:
beside = os.path.join(app_dir(), "bridge.json")
if os.path.exists(beside) or _writable(app_dir()):
return beside
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
folder = os.path.join(base, EXE_NAME)
os.makedirs(folder, exist_ok=True)
return os.path.join(folder, "bridge.json")
def new_token() -> str:
return secrets.token_urlsafe(24)
class Config:
def __init__(self, path: Optional[str] = None):
self.path = os.path.abspath(path or default_path())
self.lock = threading.RLock()
self.data: dict[str, Any] = dict(DEFAULTS)
self.load()
# -- file --------------------------------------------------------------
def load(self) -> None:
with self.lock:
data = dict(DEFAULTS)
if os.path.exists(self.path):
try:
with open(self.path, encoding="utf-8-sig") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
data.update(loaded)
except (OSError, ValueError) as e:
backup = self.path + ".broken"
log.error("%s could not be read (%s); kept as %s and started fresh", self.path, e,
backup)
try:
os.replace(self.path, backup)
except OSError:
pass
profiles = []
for raw in data.get("profiles") or []:
try:
profiles.append(validate(raw))
except (ValueError, TypeError, KeyError) as e:
log.warning("profile %r ignored: %s", (raw or {}).get("name"), e)
data["profiles"] = profiles
self.data = data
if not data.get("token"):
data["token"] = new_token()
self.save()
log.info("created %s with a new access key", self.path)
def save(self) -> None:
with self.lock:
folder = os.path.dirname(self.path)
os.makedirs(folder, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".bridge-", suffix=".json", dir=folder)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2, ensure_ascii=False)
os.replace(tmp, self.path)
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise
# -- values ------------------------------------------------------------
@property
def token(self) -> str:
return str(self.data.get("token") or "")
def rotate_token(self) -> str:
with self.lock:
self.data["token"] = new_token()
self.save()
return self.token
@property
def listen_host(self) -> str:
return str(self.data.get("listen_host") or "0.0.0.0")
@property
def listen_port(self) -> int:
try:
return int(self.data.get("listen_port") or DEFAULT_PORT)
except (TypeError, ValueError):
return DEFAULT_PORT
@property
def allowed_clients(self) -> list[str]:
value = self.data.get("allowed_clients") or []
return [str(v).strip() for v in value if str(v).strip()] if isinstance(value, list) else []
def set_allowed_clients(self, clients: list[str]) -> None:
import ipaddress
cleaned = []
for c in clients:
c = str(c).strip()
if not c:
continue
ipaddress.ip_address(c) # raises ValueError
cleaned.append(c)
with self.lock:
self.data["allowed_clients"] = cleaned
self.save()
# -- profiles ----------------------------------------------------------
def profiles(self) -> list[dict]:
with self.lock:
return [dict(p) for p in self.data.get("profiles") or []]
def profile(self, name: str) -> Optional[dict]:
name = str(name or "").strip().lower()
with self.lock:
for p in self.data.get("profiles") or []:
if p["name"].lower() == name:
return dict(p)
return None
def save_profile(self, raw: dict) -> dict:
profile = validate(raw)
with self.lock:
items = [p for p in self.data.get("profiles") or []
if p["name"].lower() != profile["name"].lower()]
if len(items) >= MAX_PROFILES:
raise ValueError(f"at most {MAX_PROFILES} profiles")
items.append(profile)
self.data["profiles"] = items
self.save()
return profile
def delete_profile(self, name: str) -> bool:
with self.lock:
items = self.data.get("profiles") or []
kept = [p for p in items if p["name"].lower() != str(name).lower()]
if len(kept) == len(items):
return False
self.data["profiles"] = kept
self.save()
return True
+339
View File
@@ -0,0 +1,339 @@
# 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
+146
View File
@@ -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")
+237
View File
@@ -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)
+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()]
+431
View File
@@ -0,0 +1,431 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
"""
Serial ports: listing them, describing a line, and keeping one open.
A `PortWorker` owns one COM port. While somebody keeps asking for data it
holds the port open, sends the request (if the device must be asked) at
the poll interval, cuts what comes back into chunks at silences, and keeps
the recent chunks. Unplugging the USB cable, a driver hiccup or a busy port
are all survived: the worker closes, waits, and opens again.
"""
from __future__ import annotations
import logging
import re
import threading
import time
from collections import deque
from dataclasses import dataclass
from typing import Any, Callable, Optional
try:
import serial
from serial.tools import list_ports as _list_ports
except ImportError: # pragma: no cover
serial = None
_list_ports = None
log = logging.getLogger("scale_bridge")
RING_SIZE = 64 # chunks kept per port
MAX_CHUNK = 512 # a continuous stream without silences is cut here
IDLE_CLOSE_S = 120 # a port nobody asked about for this long is released
MAX_REQUEST_BYTES = 64
_PORT_RE = re.compile(r"^(COM\d{1,3}|/dev/[A-Za-z0-9_./-]+)$", re.IGNORECASE)
# USB-serial chips worth a word to the person choosing a port.
KNOWN_CHIPS: dict[int, tuple[str, str]] = {
0x1A86: ("WCH CH340/CH341",
"Clone CH340 chips fail with WCH drivers 3.8 and newer on Windows "
"('A device attached to the system is not functioning'). Install driver "
"3.5.2019.1 or use an FTDI-based adapter."),
0x067B: ("Prolific PL2303",
"Counterfeit PL2303 chips are refused by newer Prolific drivers (Code 10). "
"Use an older driver or an FTDI-based adapter."),
0x0403: ("FTDI", ""),
0x10C4: ("Silicon Labs CP210x", ""),
0x0557: ("ATEN", ""),
0x110A: ("Moxa", ""),
}
def now_ms() -> int:
return int(time.monotonic() * 1000)
def parse_hex(text: str) -> bytes:
"""'4d0904 07', '0x4D 0x09', '4D-09' -> bytes. Raises ValueError."""
cleaned = re.sub(r"0x", "", str(text or ""), flags=re.IGNORECASE)
cleaned = re.sub(r"[\s,;:-]+", "", cleaned)
return bytes.fromhex(cleaned)
def format_hex(data: bytes) -> str:
return data.hex(" ").upper()
def printable(data: bytes) -> str:
names = {0x02: "<STX>", 0x03: "<ETX>", 0x0D: "<CR>", 0x0A: "<LF>", 0x05: "<ENQ>",
0x06: "<ACK>", 0x15: "<NAK>", 0x1B: "<ESC>", 0x11: "<DC1>"}
out = []
for b in data:
if b in names:
out.append(names[b])
elif 32 <= b < 127:
out.append(chr(b))
else:
out.append(f"<{b:02X}>")
return "".join(out)
# ---------------------------------------------------------------------------
# One line's settings
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class LineSettings:
port: str
baudrate: int = 9600
bytesize: int = 8
parity: str = "N"
stopbits: float = 1
dtr: bool = True
rts: bool = True
request: bytes = b"" # sent every poll interval; empty = only listen
after_ack: bytes = b"" # sent when the device answers the request with ACK
poll_s: float = 0.5
gap_s: float = 0.05 # silence that ends a chunk
@property
def framing(self) -> str:
stop = int(self.stopbits) if self.stopbits in (1, 2) else self.stopbits
return f"{self.bytesize}{self.parity}{stop}"
def describe(self) -> str:
req = f", request {format_hex(self.request)}" if self.request else ", listen only"
return f"{self.port} {self.baudrate} {self.framing}{req}"
def with_line(self, baudrate: int, framing: str) -> "LineSettings":
bits, parity, stop = int(framing[0]), framing[1], float(framing[2:])
return LineSettings(self.port, baudrate, bits, parity, stop, self.dtr, self.rts,
self.request, self.after_ack, self.poll_s, self.gap_s)
def serial_dict(self) -> dict[str, Any]:
stop = int(self.stopbits) if self.stopbits in (1, 2) else self.stopbits
return {"port": self.port, "baudrate": self.baudrate, "bytesize": self.bytesize,
"parity": self.parity, "stopbits": stop, "dtr": self.dtr, "rts": self.rts}
def exchange_dict(self) -> dict[str, Any]:
return {"request_hex": format_hex(self.request),
"after_ack_hex": format_hex(self.after_ack),
"poll_interval_ms": int(round(self.poll_s * 1000)),
"frame_gap_ms": int(round(self.gap_s * 1000))}
@classmethod
def from_dicts(cls, serial_cfg: dict, exchange_cfg: Optional[dict] = None,
port: Optional[str] = None) -> "LineSettings":
"""Validate settings that arrive over the network or from a file."""
exchange_cfg = exchange_cfg or {}
name = str(port or serial_cfg.get("port") or "").strip()
if not _PORT_RE.match(name):
raise ValueError(f"invalid port name: {name!r}")
if name.upper().startswith("COM"):
name = name.upper()
baudrate = int(serial_cfg.get("baudrate") or 9600)
if not 50 <= baudrate <= 921600:
raise ValueError(f"invalid baudrate: {baudrate}")
bytesize = int(serial_cfg.get("bytesize") or 8)
if bytesize not in (5, 6, 7, 8):
raise ValueError(f"invalid bytesize: {bytesize}")
parity = str(serial_cfg.get("parity") or "N").upper()[:1]
if parity not in "NEOMS":
raise ValueError(f"invalid parity: {parity}")
stopbits = float(serial_cfg.get("stopbits") or 1)
if stopbits not in (1, 1.5, 2):
raise ValueError(f"invalid stopbits: {stopbits}")
try:
request = parse_hex(exchange_cfg.get("request_hex") or "")
after_ack = parse_hex(exchange_cfg.get("after_ack_hex") or "")
except ValueError as e:
raise ValueError(f"invalid request hex: {e}") from e
if len(request) > MAX_REQUEST_BYTES or len(after_ack) > MAX_REQUEST_BYTES:
raise ValueError("request too long")
poll_ms = min(max(int(exchange_cfg.get("poll_interval_ms") or 500), 100), 10000)
gap_ms = min(max(int(exchange_cfg.get("frame_gap_ms") or 50), 5), 1000)
return cls(port=name, baudrate=baudrate, bytesize=bytesize, parity=parity,
stopbits=int(stopbits) if stopbits in (1, 2) else stopbits,
dtr=bool(serial_cfg.get("dtr", True)), rts=bool(serial_cfg.get("rts", True)),
request=request, after_ack=after_ack,
poll_s=poll_ms / 1000, gap_s=gap_ms / 1000)
# ---------------------------------------------------------------------------
# Opening and listing
# ---------------------------------------------------------------------------
Opener = Callable[[LineSettings], Any]
def open_real(s: LineSettings) -> Any:
if serial is None:
raise RuntimeError("pyserial is not installed")
ser = serial.Serial(port=s.port, baudrate=s.baudrate, bytesize=s.bytesize,
parity=s.parity, stopbits=s.stopbits, timeout=0.02, write_timeout=1.0)
ser.dtr = s.dtr
ser.rts = s.rts
ser.reset_input_buffer()
return ser
def _port_sort_key(name: str) -> tuple:
m = re.match(r"^COM(\d+)$", name, re.IGNORECASE)
return (0, int(m.group(1)), "") if m else (1, 0, name)
def list_serial_ports() -> list[dict[str, Any]]:
if _list_ports is None:
return []
out = []
for p in _list_ports.comports():
chip, note = KNOWN_CHIPS.get(p.vid, (None, "")) if p.vid is not None else (None, "")
text = f"{p.description or ''} {p.hwid or ''}".lower()
kind = "bluetooth" if ("bluetooth" in text or "bthenum" in text) else (
"usb" if p.vid is not None else "native")
out.append({
"device": p.device,
"description": p.description,
"hwid": p.hwid,
"vid": f"{p.vid:04X}" if p.vid is not None else None,
"pid": f"{p.pid:04X}" if p.pid is not None else None,
"manufacturer": p.manufacturer,
"chip": chip,
"note": note,
"kind": kind,
})
return sorted(out, key=lambda p: _port_sort_key(p["device"]))
# ---------------------------------------------------------------------------
# A worker per port
# ---------------------------------------------------------------------------
class PortWorker(threading.Thread):
def __init__(self, settings: LineSettings, opener: Opener):
super().__init__(name=f"port-{settings.port}", daemon=True)
self.settings = settings
self.opener = opener
self.chunks: deque[tuple[int, bytes]] = deque(maxlen=RING_SIZE)
self.cond = threading.Condition()
self.ser: Any = None
self.last_error: Optional[str] = None
self.last_used = time.monotonic()
self.first_attempt = threading.Event()
self._halt = threading.Event()
# Called with (t_ms, bytes) for every chunk, outside the lock.
self.listeners: list[Callable[[int, bytes], None]] = []
self.stats = {"opened_at": None, "bytes_rx": 0, "frames_rx": 0, "requests_tx": 0,
"open_failures": 0}
# -- lifecycle ---------------------------------------------------------
def touch(self) -> None:
self.last_used = time.monotonic()
def stop(self) -> None:
self._halt.set()
@property
def stopped(self) -> bool:
return self._halt.is_set()
def _open(self) -> bool:
try:
self.ser = self.opener(self.settings)
self.last_error = None
self.stats["opened_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
log.info("%s opened", self.settings.describe())
return True
except Exception as e: # noqa: BLE001
self.ser = None
self.stats["open_failures"] += 1
message = f"could not open port {self.settings.port}: {e}"
if message != self.last_error:
log.warning("%s", message)
self.last_error = message
return False
finally:
self.first_attempt.set()
with self.cond:
self.cond.notify_all()
def _close(self) -> None:
if self.ser is not None:
try:
self.ser.close()
except Exception: # noqa: BLE001
pass
self.ser = None
def _push(self, data: bytes, t_ms: int) -> None:
with self.cond:
self.chunks.append((t_ms, data))
self.stats["frames_rx"] += 1
self.cond.notify_all()
for listener in list(self.listeners):
try:
listener(t_ms, data)
except Exception: # noqa: BLE001
log.exception("chunk listener failed")
def run(self) -> None:
s = self.settings
buf = bytearray()
last_rx = 0.0
next_request = 0.0
while not self._halt.is_set():
if time.monotonic() - self.last_used > IDLE_CLOSE_S:
log.info("%s idle for %ds, released", s.port, IDLE_CLOSE_S)
break
if self.ser is None:
if not self._open():
self._halt.wait(2.0)
continue
buf.clear()
next_request = 0.0
try:
now = time.monotonic()
if s.request and now >= next_request:
self.ser.write(s.request)
self.stats["requests_tx"] += 1
next_request = now + s.poll_s
waiting = self.ser.in_waiting
chunk = self.ser.read(waiting if waiting else 1)
now = time.monotonic()
if chunk:
if s.after_ack and b"\x06" in chunk:
self.ser.write(s.after_ack)
buf += chunk
last_rx = now
self.stats["bytes_rx"] += len(chunk)
if len(buf) >= MAX_CHUNK:
self._push(bytes(buf), int(now * 1000))
buf.clear()
continue
if buf and now - last_rx >= s.gap_s:
self._push(bytes(buf), int(last_rx * 1000))
buf.clear()
except Exception as e: # noqa: BLE001
self.last_error = f"{s.port}: {e.__class__.__name__}: {e}"
log.warning("%s lost: %s", s.port, e)
self._close()
self._halt.wait(1.0)
self._close()
self._halt.set()
with self.cond:
self.cond.notify_all()
# -- reading -----------------------------------------------------------
def wait_fresh(self, max_age_ms: int, wait_ms: int) -> None:
"""Return once a chunk younger than max_age_ms exists, or wait_ms passed."""
self.first_attempt.wait(timeout=min(wait_ms, 3000) / 1000)
deadline = time.monotonic() + wait_ms / 1000
with self.cond:
while True:
if self.chunks and now_ms() - self.chunks[-1][0] <= max_age_ms:
return
if self.ser is None and self.last_error:
return
remaining = deadline - time.monotonic()
if remaining <= 0 or self._halt.is_set():
return
self.cond.wait(timeout=remaining)
def recent(self, count: int) -> list[tuple[int, bytes]]:
with self.cond:
return list(self.chunks)[-count:] if count > 0 else []
@property
def is_open(self) -> bool:
return self.ser is not None
def snapshot(self, count: int) -> dict[str, Any]:
chunks = self.recent(count)
is_open = self.is_open
return {
"ok": is_open or bool(chunks and not self.last_error),
"open": is_open,
"error": None if is_open else self.last_error,
"now": now_ms(),
"port": self.settings.port,
"frames": [{"t": t, "hex": format_hex(d)} for t, d in chunks],
"stats": dict(self.stats),
}
class PortBusy(RuntimeError):
"""The port is reserved (auto-detection is running on it)."""
def port_key(port: str) -> str:
return port.upper() if port.upper().startswith("COM") else port
class Registry:
"""At most one worker per port; new settings for a port reopen it."""
def __init__(self, opener: Opener):
self.opener = opener
self.lock = threading.Lock()
self.workers: dict[str, PortWorker] = {}
self.reserved: set[str] = set()
def reserve(self, ports: list[str]) -> None:
"""Close these ports and keep them closed until `unreserve` (for auto-detection)."""
with self.lock:
self.reserved.update(port_key(p) for p in ports)
for p in ports:
self.release(p)
def unreserve(self, ports: list[str]) -> None:
with self.lock:
self.reserved.difference_update(port_key(p) for p in ports)
def worker_for(self, settings: LineSettings) -> PortWorker:
with self.lock:
if port_key(settings.port) in self.reserved:
raise PortBusy(f"{settings.port} is busy: auto-detection is running on it")
current = self.workers.get(settings.port)
if current is not None and (current.stopped or current.settings != settings):
if not current.stopped:
log.info("%s settings changed, reopening", settings.port)
current.stop()
current.join(timeout=3)
current = None
if current is None:
current = PortWorker(settings, self.opener)
self.workers[settings.port] = current
current.start()
current.touch()
return current
def release(self, port: str) -> None:
"""Close a port so something else (auto-detect) can open it."""
with self.lock:
worker = self.workers.pop(port_key(port), None)
if worker is not None:
worker.stop()
worker.join(timeout=3)
def active_ports(self) -> list[str]:
with self.lock:
return [p for p, w in self.workers.items() if not w.stopped]
def stop_all(self) -> None:
with self.lock:
workers = list(self.workers.values())
self.workers.clear()
for w in workers:
w.stop()
for w in workers:
w.join(timeout=3)
+572
View File
@@ -0,0 +1,572 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
"""
The HTTP side of the bridge.
For the network (any system that needs the weight) — JSON, and
the header `X-Bridge-Token: <access key>` on everything except /health:
GET /health service, version, host (no key needed)
GET /ports COM ports on this PC
POST /read raw frames for given line settings
(the raw-frames contract of earlier versions)
GET /profiles saved scale profiles
GET /weight?profile=NAME the current weight, parsed and checked
GET /weight?port=COM7&protocol=mt_sics[&baud=9600&framing=8N1]
POST /weight {"profile": NAME} or a whole profile
For the page on this PC only (loopback address, and the header
`X-Requested-With: ScaleBridge` so another web page cannot drive it):
GET / the user interface
GET /api/state, /api/protocols, /api/weight, /api/monitor
POST /api/detect, /api/detect/cancel, /api/profiles, /api/token,
/api/allowed, /api/firewall, /api/autostart
GET /api/detect progress and results of auto-detection
DELETE /api/profiles/NAME
"""
from __future__ import annotations
import hmac
import json
import logging
import socket
import sys
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Optional
from urllib.parse import parse_qs, unquote, urlsplit
from . import ABOUT, PRODUCT, SERVICE_ID, __version__, profiles, winutil
from .config import Config
from .detect import Detector, Timings
from .labels import Keeper, LabelLog, labels_folder, on_print
from .protocols import PROTOCOLS, catalogue_info, get_protocol
from .serialio import (RING_SIZE, LineSettings, Opener, PortBusy, Registry, format_hex,
list_serial_ports, now_ms, open_real, printable)
from .simulator import SimulatorBank
from .webui import page, remote_page
log = logging.getLogger("scale_bridge")
MAX_BODY = 64 * 1024
LOCAL_HEADER = ("X-Requested-With", "ScaleBridge")
STATUS_CACHE_S = 15
def _framing_ok(text: str) -> bool:
return len(text) == 3 and text[0] in "5678" and text[1] in "NEOMS" and text[2] in "12"
class DetectionJob:
def __init__(self, bridge: "Bridge", ports: list[str], thorough: bool,
print_wait_s: float = 0, listen_only: bool = False):
self.id = uuid.uuid4().hex[:12]
self.bridge = bridge
self.ports = ports
self.thorough = thorough
self.cancel = threading.Event()
self.lock = threading.Lock()
self.log: list[dict] = []
self.results: list[dict] = []
self.running = True
self.started = time.time()
self.finished: Optional[float] = None
self.detector = Detector(bridge.opener, log=self._add, cancelled=self.cancel.is_set,
thorough=thorough, timings=bridge.detect_timings,
print_wait_s=print_wait_s, listen_only=listen_only)
self.print_wait_s = print_wait_s
self.listen_only = listen_only
self.thread = threading.Thread(target=self._run, name="detect", daemon=True)
def _add(self, code: str, **details: Any) -> None:
with self.lock:
self.log.append({"t": round(time.time() - self.started, 2), "code": code, **details})
if code in ("found", "not_found", "open_failed"):
log.info("detect %s %s", code, details)
def _run(self) -> None:
registry = self.bridge.registry
registry.reserve(self.ports)
try:
for port in self.ports:
if self.cancel.is_set():
break
result = self.detector.run_port(port)
with self.lock:
self.results.append(result)
self.detector.steps_done = 0
self._add("done", found=sum(1 for r in self.results if r.get("found")),
cancelled=self.cancel.is_set())
except Exception as e: # noqa: BLE001
log.exception("auto-detection failed")
self._add("error", error=str(e))
finally:
registry.unreserve(self.ports)
self.running = False
self.finished = time.time()
def start(self) -> None:
self.thread.start()
def status(self, since: int = 0) -> dict:
with self.lock:
if self.running:
current = min(self.detector.progress, 0.99)
progress = min(0.99, (len(self.results) + current) / max(1, len(self.ports)))
else:
progress = 1.0
return {"id": self.id, "running": self.running, "ports": self.ports,
"thorough": self.thorough, "print_wait_s": self.print_wait_s,
"listen_only": self.listen_only, "progress": round(progress, 3),
"log": self.log[since:], "log_size": len(self.log),
"results": list(self.results),
"elapsed_s": round((self.finished or time.time()) - self.started, 1)}
class Bridge:
"""Everything the handlers share."""
def __init__(self, config: Config, simulate: str = ""):
self.config = config
self.simulator = SimulatorBank(simulate) if simulate else None
self.opener: Opener = self.simulator.opener(open_real) if self.simulator else open_real
self.registry = Registry(self.opener)
self.job: Optional[DetectionJob] = None
self.job_lock = threading.Lock()
self._status_cache: dict[str, tuple[float, dict]] = {}
self.started = time.time()
self.listen_port = config.listen_port
self.detect_timings: Optional[Timings] = None
# Print-only scales: keep their ports open and log every label.
self.labels = LabelLog(labels_folder(config.path))
self.keeper = Keeper(self.registry, config.profiles, self.labels)
self.keeper.start()
# -- ports and status --------------------------------------------------
def ports(self) -> list[dict]:
ports = list_serial_ports()
if self.simulator:
ports += self.simulator.ports()
return ports
def cached_status(self, key: str, fn, fresh: bool = False) -> dict:
now = time.time()
hit = self._status_cache.get(key)
if hit and not fresh and now - hit[0] < STATUS_CACHE_S:
return hit[1]
value = fn()
self._status_cache[key] = (now, value)
return value
def forget_status(self) -> None:
self._status_cache.clear()
# -- reading -----------------------------------------------------------
def read_raw(self, body: dict) -> dict:
"""POST /read: the frames as they arrived (raw-frames contract of earlier versions)."""
settings = LineSettings.from_dicts(body.get("serial") or {}, body.get("exchange") or {})
wait_ms = min(max(int(body.get("wait_ms") or 2500), 0), 10000)
max_age_ms = min(max(int(body.get("max_age_ms") or 1500), 50), 60000)
count = min(max(int(body.get("frames") or 5), 0), RING_SIZE)
worker = self.registry.worker_for(settings)
worker.wait_fresh(max_age_ms, wait_ms)
worker.touch()
return worker.snapshot(count)
def weight(self, profile: dict, max_age_ms: int = 1500, wait_ms: int = 2500) -> dict:
worker = self.registry.worker_for(profiles.line_settings(profile))
result = profiles.wait_for_reading(profile, worker, max_age_ms, wait_ms, now_ms)
worker.touch()
return result
def label_list(self, query: dict) -> dict:
name = str(query.get("profile") or "").strip() or None
if name and self.config.profile(name) is None:
raise LookupError(f"no profile named {name!r}")
if name:
name = self.config.profile(name)["name"]
after = int(query.get("after") or 0)
limit = min(max(int(query.get("limit") or 100), 1), 500)
records = self.labels.list(name, after, limit)
return {"ok": True, "labels": records, "last_seq": self.labels.seq,
"listening": [p["name"] for p in self.config.profiles() if on_print(p)]}
def resolve_profile(self, query: dict) -> dict:
"""A saved profile by name, or one built from the request."""
name = str(query.get("profile") or "").strip()
if name:
profile = self.config.profile(name)
if profile is None:
raise LookupError(f"no profile named {name!r}")
return profile
if isinstance(query.get("serial"), dict):
return profiles.validate({"name": "request", **query})
port = str(query.get("port") or "").strip()
protocol = str(query.get("protocol") or "").strip()
if not port or not protocol:
raise ValueError("give ?profile=NAME, or ?port=COMx&protocol=ID")
serial_cfg: dict[str, Any] = {}
if query.get("baud"):
serial_cfg["baudrate"] = int(query["baud"])
framing = str(query.get("framing") or "").upper()
if framing:
if not _framing_ok(framing):
raise ValueError(f"invalid framing: {framing!r} (e.g. 8N1, 7E1)")
serial_cfg.update(bytesize=int(framing[0]), parity=framing[1], stopbits=int(framing[2]))
exchange = {}
if query.get("request_hex") is not None:
exchange = {"request_hex": str(query["request_hex"]),
"poll_interval_ms": int(query.get("poll_ms") or PROTOCOLS.get(protocol, PROTOCOLS["ascii_generic"]).default_poll_ms)}
profile = profiles.ad_hoc(port, protocol, serial_cfg, exchange or None)
if query.get("unit"):
profile["unit"] = str(query["unit"])
return profile
def monitor(self, query: dict) -> dict:
port = str(query.get("port") or "")
framing = str(query.get("framing") or "8N1").upper()
if not _framing_ok(framing):
raise ValueError(f"invalid framing: {framing!r}")
serial_cfg = {"port": port, "baudrate": int(query.get("baud") or 9600),
"bytesize": int(framing[0]), "parity": framing[1], "stopbits": int(framing[2])}
exchange = {"request_hex": str(query.get("request_hex") or ""),
"after_ack_hex": str(query.get("after_ack_hex") or ""),
"poll_interval_ms": int(query.get("poll_ms") or 500)}
settings = LineSettings.from_dicts(serial_cfg, exchange)
worker = self.registry.worker_for(settings)
worker.wait_fresh(1500, 1500)
chunks = worker.recent(24)
joined = b"".join(d for _, d in chunks)
guesses = []
for pid, proto in PROTOCOLS.items():
if pid.startswith("custom"):
continue
found = [r for r in proto.readings(joined) if r.value is not None]
if found:
last = found[-1]
guesses.append({"protocol": pid, "name": proto.name, "count": len(found),
"value": last.value, "unit": last.unit, "stable": last.stable,
"specificity": proto.specificity})
guesses.sort(key=lambda g: (-g["specificity"], -g["count"]))
now = now_ms()
return {"ok": worker.is_open, "error": None if worker.is_open else worker.last_error,
"settings": settings.describe(), "stats": dict(worker.stats),
"frames": [{"age_ms": now - t, "hex": format_hex(d), "text": printable(d)}
for t, d in chunks[-16:]],
"guesses": guesses}
# -- auto-detection ----------------------------------------------------
def start_detection(self, ports: Optional[list[str]], thorough: bool, bluetooth: bool,
print_wait_s: float = 0, listen_only: bool = False) -> DetectionJob:
with self.job_lock:
if self.job and self.job.running:
raise PortBusy("auto-detection is already running")
available = self.ports()
if ports:
names = {p["device"].upper(): p["device"] for p in available}
chosen = []
for p in ports:
key = str(p).upper()
if key not in names:
raise ValueError(f"no such port: {p}")
chosen.append(names[key])
else:
chosen = [p["device"] for p in available if bluetooth or p.get("kind") != "bluetooth"]
if not chosen:
raise ValueError("no COM ports to search — is the USB-serial adapter plugged in "
"and its driver installed?")
self.job = DetectionJob(self, chosen, thorough, min(max(float(print_wait_s or 0), 0), 300),
listen_only)
self.job.start()
return self.job
def state(self, fresh: bool = False) -> dict:
port = self.listen_port
return {
"ok": True,
"about": ABOUT,
"service": SERVICE_ID,
"host": socket.gethostname(),
"addresses": winutil.lan_addresses(),
"listen_port": port,
"token": self.config.token,
"config_path": self.config.path,
"allowed_clients": self.config.allowed_clients,
"simulate": bool(self.simulator),
"is_admin": winutil.is_admin(),
"is_windows": winutil.is_windows(),
"firewall": self.cached_status("firewall", lambda: winutil.firewall_status(port), fresh),
"autostart": self.cached_status("autostart", winutil.autostart_status, fresh),
"profiles": self.config.profiles(),
"active_ports": self.registry.active_ports(),
"uptime_s": int(time.time() - self.started),
}
class Server(ThreadingHTTPServer):
daemon_threads = True
# On Windows SO_REUSEADDR lets a second program take the same port;
# exclusive use makes a second copy of the bridge fail cleanly instead.
allow_reuse_address = sys.platform != "win32"
def server_bind(self) -> None:
if sys.platform == "win32" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
super().server_bind()
class Handler(BaseHTTPRequestHandler):
server_version = f"{SERVICE_ID}/{__version__}"
bridge: Bridge
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args: Any) -> None:
log.debug("%s %s", self.client_address[0], fmt % args)
# -- responses ---------------------------------------------------------
def _headers(self, code: int, content_type: str, length: int) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(length))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
def _send(self, code: int, body: dict) -> None:
data = json.dumps(body, ensure_ascii=False, default=str).encode("utf-8")
self._headers(code, "application/json; charset=utf-8", len(data))
self.end_headers()
self.wfile.write(data)
def _send_html(self, html: str, code: int = 200) -> None:
data = html.encode("utf-8")
self._headers(code, "text/html; charset=utf-8", len(data))
self.send_header("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
self.end_headers()
self.wfile.write(data)
def _error(self, code: int, message: str) -> None:
self._send(code, {"ok": False, "error": message})
# -- who is asking -----------------------------------------------------
@property
def _client(self) -> str:
return self.client_address[0]
def _is_loopback(self) -> bool:
ip = self._client
return ip.startswith("127.") or ip in ("::1", "::ffff:127.0.0.1")
def _host_is_local(self) -> bool:
host = (self.headers.get("Host") or "").strip().lower()
if host.startswith("["):
name = host[1:host.find("]")] if "]" in host else host
else:
name = host.split(":")[0]
return name in ("127.0.0.1", "localhost", "::1")
def _local_page_ok(self) -> bool:
return self._is_loopback() and self._host_is_local()
def _local_api_ok(self) -> bool:
if not self._local_page_ok() or self.headers.get(LOCAL_HEADER[0]) != LOCAL_HEADER[1]:
self._error(403, "this part of the bridge is only for the page on this PC")
return False
return True
def _client_allowed(self) -> bool:
allowed = self.bridge.config.allowed_clients
if allowed and not self._is_loopback() and self._client not in allowed:
self._error(403, f"client {self._client} is not in allowed_clients")
return False
return True
def _authorized(self) -> bool:
given = (self.headers.get("X-Bridge-Token") or "").encode()
expected = self.bridge.config.token.encode()
if not expected or not hmac.compare_digest(given, expected):
self._error(401, "invalid or missing X-Bridge-Token")
return False
return True
def _body(self) -> dict:
length = int(self.headers.get("Content-Length") or 0)
if length > MAX_BODY:
raise ValueError("body too large")
raw = self.rfile.read(length) if length else b""
body = json.loads(raw or b"{}")
if not isinstance(body, dict):
raise ValueError("body must be a JSON object")
return body
def _query(self) -> tuple[str, dict]:
parts = urlsplit(self.path)
query = {k: v[-1] for k, v in parse_qs(parts.query).items()}
return parts.path.rstrip("/") or "/", query
# -- dispatch ----------------------------------------------------------
def do_GET(self) -> None: # noqa: N802
self._dispatch("GET")
def do_POST(self) -> None: # noqa: N802
self._dispatch("POST")
def do_DELETE(self) -> None: # noqa: N802
self._dispatch("DELETE")
def _dispatch(self, method: str) -> None:
path, query = self._query()
try:
if path.startswith("/api/"):
if self._local_api_ok():
self._local_api(method, path, query)
return
if path == "/" and method == "GET":
if self._local_page_ok():
self._send_html(page())
else:
self._send_html(remote_page(self.bridge.listen_port))
return
if path == "/favicon.ico":
self.send_response(204)
self.send_header("Content-Length", "0")
self.end_headers()
return
if not self._client_allowed():
return
self._network_api(method, path, query)
except PortBusy as e:
self._error(409, str(e))
except LookupError as e:
self._error(404, str(e))
except (ValueError, TypeError, KeyError) as e:
self._error(400, str(e))
except Exception as e: # noqa: BLE001
log.exception("%s %s failed", method, path)
self._error(500, f"{e.__class__.__name__}: {e}")
def _network_api(self, method: str, path: str, query: dict) -> None:
b = self.bridge
if path == "/health" and method == "GET":
self._send(200, {"ok": True, "service": SERVICE_ID, "product": PRODUCT,
"version": __version__, "host": socket.gethostname(),
"simulate": bool(b.simulator), "author": ABOUT["author"],
"website": ABOUT["website"], "repository": ABOUT["repository"],
"capabilities": ["read", "ports", "weight", "profiles", "detect"]})
return
if not self._authorized():
return
if path == "/ports" and method == "GET":
self._send(200, {"ok": True, "ports": b.ports()})
elif path == "/read" and method == "POST":
self._send(200, b.read_raw(self._body()))
elif path == "/profiles" and method == "GET":
self._send(200, {"ok": True, "profiles": b.config.profiles()})
elif path == "/labels" and method == "GET":
self._send(200, b.label_list(query))
elif path == "/weight" and method in ("GET", "POST"):
body = query if method == "GET" else {**query, **self._body()}
profile = b.resolve_profile(body)
self._send(200, b.weight(profile, *self._timing(body)))
else:
self._error(404, "not found")
@staticmethod
def _timing(q: dict) -> tuple[int, int]:
max_age = min(max(int(q.get("max_age_ms") or 1500), 50), 60000)
wait = min(max(int(q.get("wait_ms") or 2500), 0), 10000)
return max_age, wait
def _local_api(self, method: str, path: str, query: dict) -> None:
b = self.bridge
if method == "GET":
if path == "/api/state":
self._send(200, b.state(fresh=query.get("fresh") == "1"))
elif path == "/api/ports":
self._send(200, {"ok": True, "ports": b.ports()})
elif path == "/api/protocols":
self._send(200, {"ok": True, "protocols": catalogue_info()})
elif path == "/api/weight":
profile = b.resolve_profile(query)
self._send(200, b.weight(profile, *self._timing(query)))
elif path == "/api/labels":
self._send(200, b.label_list(query))
elif path == "/api/monitor":
self._send(200, b.monitor(query))
elif path == "/api/detect":
job = b.job
since = int(query.get("since") or 0)
self._send(200, {"ok": True, "job": job.status(since) if job else None})
else:
self._error(404, "not found")
return
if method == "DELETE":
if path.startswith("/api/profiles/"):
name = unquote(path[len("/api/profiles/"):])
if not b.config.delete_profile(name):
raise LookupError(f"no profile named {name!r}")
self._send(200, {"ok": True, "profiles": b.config.profiles()})
else:
self._error(404, "not found")
return
body = self._body()
if path == "/api/detect":
job = b.start_detection(body.get("ports") or None, bool(body.get("thorough")),
bool(body.get("bluetooth")), body.get("print_wait_s") or 0,
bool(body.get("listen_only")))
self._send(200, {"ok": True, "job": job.status()})
elif path == "/api/detect/cancel":
if b.job and b.job.running:
b.job.cancel.set()
self._send(200, {"ok": True})
elif path == "/api/profiles":
if isinstance(body.get("detection"), dict):
profile = profiles.from_detection(body["detection"], str(body.get("name") or ""),
str(body.get("label") or ""))
if body.get("unit"):
profile["unit"] = profiles.norm_unit(body["unit"])
else:
profile = body
saved = b.config.save_profile(profile)
b.registry.release(saved["port"])
b.keeper.tick()
self._send(200, {"ok": True, "profile": saved, "profiles": b.config.profiles()})
elif path == "/api/token":
self._send(200, {"ok": True, "token": b.config.rotate_token()})
elif path == "/api/allowed":
clients = body.get("clients") or []
if not isinstance(clients, list):
raise ValueError("clients must be a list")
b.config.set_allowed_clients(clients)
self._send(200, {"ok": True, "allowed_clients": b.config.allowed_clients})
elif path == "/api/firewall":
remote = str(body.get("remote_ip") or "").strip() or None
if remote:
import ipaddress
ipaddress.ip_address(remote)
result = (winutil.firewall_close() if body.get("remove")
else winutil.firewall_open(b.listen_port, remote))
b.forget_status()
self._send(200, {**result, "state": b.state(fresh=True)})
elif path == "/api/autostart":
result = (winutil.autostart_enable(b.config.path) if body.get("enable")
else winutil.autostart_disable())
b.forget_status()
self._send(200, {**result, "state": b.state(fresh=True)})
else:
self._error(404, "not found")
def make_server(bridge: Bridge, host: str, port: int) -> Server:
Handler.bridge = bridge
bridge.listen_port = port
return Server((host, port), Handler)
+222
View File
@@ -0,0 +1,222 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
"""
Simulated scales — every protocol in the library, on a fake COM port.
For trying the program without hardware and for the tests. A simulated
device only understands the speed and framing it was configured with,
exactly like a real one: at another speed a continuous device produces
garbage and a polled one stays silent; a 7E1 device read as 8N1 shows its
parity bit in the top bit of each byte.
--simulate mahak_mds COM90, 9600 8N1
--simulate toledo_cont:4800:7E1,mt_sics COM90 and COM91
"""
from __future__ import annotations
import time
from typing import Optional
from .serialio import LineSettings, Opener, open_real
FIRST_PORT = 90
# The weight walks through an empty scale, 550 g, a ~13 kg package that
# settles, and 750 g — (seconds, [kg values], stable).
SCHEDULE = [(6, [0.0], True), (8, [0.55], True), (3, [12.85, 13.15, 13.10], False),
(8, [13.05], True), (6, [0.75], True)]
def weight_now(t0: float) -> tuple[float, bool]:
total = sum(s for s, _, _ in SCHEDULE)
t = (time.monotonic() - t0) % total
for seconds, values, stable in SCHEDULE:
if t < seconds:
return values[int(t * 3) % len(values)], stable
t -= seconds
return 0.0, True
def _even_parity_bit(b: int) -> int:
return (bin(b & 0x7F).count("1") & 1) << 7
def frame_for(protocol: str, kg: float, stable: bool) -> bytes:
"""What `protocol` sends for `kg` — the same shapes the parsers read."""
if protocol == "mt_sics":
return f"S {'S' if stable else 'D'} {kg:10.3f} kg\r\n".encode()
if protocol == "and_std":
return f"{'ST' if stable else 'US'},GS,{kg:+09.3f}kg\r\n".encode()
if protocol == "toledo_cont":
digits = f"{round(abs(kg) * 1000):06d}".encode()
swa = 0x20 | 0x08 | 5 # count-by x1, 0.00X
swb = 0x20 | 0x10 | (0 if stable else 0x08) | (0x02 if kg < 0 else 0)
swc = 0x20
body = bytes([0x02, swa, swb, swc]) + digits + b"000000" + b"\r"
return body + bytes([(-sum(b & 0x7F for b in body)) & 0x7F])
if protocol == "sma":
return b"\n 1G" + (b" " if stable else b"M") + b" " + f"{kg:10.3f}".encode() + b"kg \r"
if protocol == "nci":
return b"\n" + f"{kg:8.3f}kg".encode() + b"\r\n" + (b"0" if stable else b"1") + b"0\r\x03"
if protocol == "ohaus":
return f"{kg:10.3f} kg{'' if stable else ' ?'}\r\n".encode()
if protocol == "sartorius":
return f"+{kg:10.3f} {'kg' if stable else ' '}\r\n".encode()
if protocol == "cas_enq":
return b"\x01\x02" + (b"S" if stable else b"U") + f" {kg:7.3f}kg".encode() + b"\x03\x04"
if protocol == "xk3190_tf0":
body = (b"-" if kg < 0 else b"+") + f"{round(abs(kg) * 1000):06d}".encode() + b"3"
x = 0
for b in body:
x ^= b
return b"\x02" + body + f"{x >> 4:X}{x & 0xF:X}".encode() + b"\x03"
if protocol == "xk3190_reversed":
return f"{kg:08.3f}"[::-1].encode() + b"="
if protocol == "label_print":
# What an indicator wired to a TSC label printer sends on Print (TSPL).
w = f"{kg:7.3f}"
return ('CLS\r\nSIZE 57 mm, 40 mm\r\nGAP 2 mm,0\r\nDIRECTION 1\r\nREFERENCE 0,0\r\n'
'CODEPAGE IranII\r\nTEXT 300,250,"10",270,1,1,"No: 6 SHIFT:2"\r\n'
'TEXT 030,235,"10",0,1,2,"Order.Co : 222\r\n'
'TEXT 030,080,"10",0,1,2,"1405/07/19 - 16:44"\r\n'
'TEXT 030,160,"10",0,1,2,"Thickness : 0.0"\r\n'
f'TEXT 020,000,"10",0,2,2,"{w}"\r\nTEXT 200,015,"10",0,2,1,"(Kg)"\r\n'
f'BARCODE 350,280, "128",070,1,270,2,2, " 222\t{w}"\r\nPRINT 1\r\n\r\n').encode()
if protocol == "mahak_mds":
grams = round(kg * 1000)
return bytes([0x58, (grams * 7 + 0x51) & 0xFF, 0x07, 0x07]) + grams.to_bytes(3, "big")
return f"G {kg:.3f} kg\r\n".encode()
class SimulatedDevice:
"""Quacks like a pyserial Serial for what the bridge uses."""
CONTINUOUS = {"toledo_cont", "xk3190_tf0", "xk3190_reversed", "ascii_generic"}
# Sends only when somebody presses Print — here every PRINT_EVERY_S seconds.
ON_PRINT = {"label_print"}
PRINT_EVERY_S = 3.0
REQUESTS = {"mt_sics": b"SI\r\n", "and_std": b"Q\r\n", "sma": b"\nW\r", "nci": b"W\r",
"ohaus": b"IP\r\n", "sartorius": b"\x1bP\r\n", "mahak_mds": bytes.fromhex("4D090407"),
"cas_enq": b"\x05"}
BOOT = bytes.fromhex("42 03 04 01 42 4F 4F 54 0C 04 8C")
def __init__(self, protocol: str, baudrate: int, framing: str, opened: LineSettings):
self.protocol = protocol
self.device_baud = baudrate
self.device_framing = framing
self.opened = opened
self.is_open = True
self._t0 = time.monotonic()
self._out = bytearray()
self._due: list[tuple[float, bytes]] = []
self._next_emit = time.monotonic() + (self.PRINT_EVERY_S if protocol in self.ON_PRINT else 0)
self._next_boot = time.monotonic() + 60
self._garbage_seed = 0x5A
# -- line matching -----------------------------------------------------
@property
def _baud_ok(self) -> bool:
return self.opened.baudrate == self.device_baud
@property
def _framing_ok(self) -> bool:
return self.opened.framing == self.device_framing
def _as_received(self, data: bytes) -> bytes:
"""What the host's UART makes of the device's bytes."""
if not self._baud_ok:
out = bytearray()
for _ in range(max(1, len(data) // 3)):
self._garbage_seed = (self._garbage_seed * 73 + 41) & 0xFF
out.append(self._garbage_seed | 0x80)
return bytes(out)
if self._framing_ok:
return data
if self.device_framing.startswith("7") and self.opened.bytesize == 8:
return bytes((b & 0x7F) | _even_parity_bit(b) for b in data)
if self.device_framing.startswith("8") and self.opened.bytesize == 7:
return bytes(b & 0x7F for b in data)
return data
# -- pyserial surface --------------------------------------------------
def _pump(self) -> None:
now = time.monotonic()
if self.protocol in self.CONTINUOUS and now >= self._next_emit:
kg, stable = weight_now(self._t0)
self._out += self._as_received(frame_for(self.protocol, kg, stable))
self._next_emit = now + 0.2
if self.protocol in self.ON_PRINT and now >= self._next_emit:
kg, _ = weight_now(self._t0)
self._out += self._as_received(frame_for(self.protocol, kg, True))
self._next_emit = now + self.PRINT_EVERY_S
if self.protocol == "mahak_mds" and now >= self._next_boot:
self._out += self._as_received(self.BOOT)
self._next_boot = now + 60
ready = [d for d in self._due if d[0] <= now]
self._due = [d for d in self._due if d[0] > now]
for _, data in ready:
self._out += self._as_received(data)
def write(self, data: bytes) -> int:
if not (self._baud_ok and self._framing_ok):
return len(data)
data = bytes(data)
request = self.REQUESTS.get(self.protocol)
kg, stable = weight_now(self._t0)
if self.protocol == "cas_enq":
if b"\x05" in data:
self._due.append((time.monotonic() + 0.03, b"\x06"))
if b"\x11" in data:
self._due.append((time.monotonic() + 0.04, frame_for("cas_enq", kg, stable)))
elif request and request in data:
self._due.append((time.monotonic() + 0.04, frame_for(self.protocol, kg, stable)))
return len(data)
@property
def in_waiting(self) -> int:
self._pump()
return len(self._out)
def read(self, n: int = 1) -> bytes:
self._pump()
if not self._out:
time.sleep(0.02)
self._pump()
chunk = bytes(self._out[:n])
del self._out[:n]
return chunk
def reset_input_buffer(self) -> None:
self._out.clear()
def close(self) -> None:
self.is_open = False
class SimulatorBank:
"""Fake ports COM90, COM91 …; every other port opens for real."""
def __init__(self, spec: str):
self.devices: dict[str, tuple[str, int, str]] = {}
for i, item in enumerate(filter(None, (s.strip() for s in spec.split(",")))):
parts = item.split(":")
protocol = parts[0]
baud = int(parts[1]) if len(parts) > 1 and parts[1] else 9600
framing = (parts[2] if len(parts) > 2 and parts[2] else "8N1").upper()
self.devices[f"COM{FIRST_PORT + i}"] = (protocol, baud, framing)
def opener(self, fallback: Optional[Opener] = None) -> Opener:
real = fallback or open_real
def _open(settings: LineSettings):
device = self.devices.get(settings.port.upper())
if device is None:
return real(settings)
return SimulatedDevice(device[0], device[1], device[2], settings)
return _open
def ports(self) -> list[dict]:
return [{"device": port, "description": f"Simulated scale ({proto}, {baud} {framing})",
"hwid": "SIMULATED", "vid": None, "pid": None, "manufacturer": "simulator",
"chip": None, "note": "", "kind": "simulated"}
for port, (proto, baud, framing) in self.devices.items()]
+864
View File
@@ -0,0 +1,864 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
# Additional terms under GPLv3 section 7(b): the author attribution shown in
# this user interface must be kept (see NOTICE.md).
"""The user interface: one self-contained page, English and Persian."""
from __future__ import annotations
import json
from . import ABOUT
ICON = ("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E"
"%3Crect width='64' height='64' rx='14' fill='%230f766e'/%3E"
"%3Crect x='11' y='13' width='42' height='6' rx='2.5' fill='%23fff'/%3E"
"%3Crect x='29' y='19' width='6' height='9' fill='%23fff'/%3E"
"%3Cpath d='M18 27h28l5 24H13z' fill='%23fff'/%3E"
"%3Crect x='23' y='34' width='18' height='8' rx='2' fill='%2308403c'/%3E"
"%3Crect x='26' y='37' width='12' height='2' rx='1' fill='%23fff'/%3E%3C/svg%3E")
_STYLE = r"""
:root{--bg:#f4f6f8;--card:#fff;--ink:#16202a;--muted:#5b6b7a;--line:#dde3e9;--brand:#0f766e;
--brand-ink:#fff;--soft:#e6f4f2;--ok:#15803d;--warn:#b45309;--bad:#b91c1c;--mono:Consolas,"Cascadia Mono",monospace;
--shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.08)}
@media (prefers-color-scheme:dark){:root{--bg:#0e1419;--card:#161e25;--ink:#e6edf3;--muted:#93a4b3;
--line:#26323d;--brand:#2dd4bf;--brand-ink:#062b27;--soft:#12302d;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171;
--shadow:none}}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.55 "Segoe UI",Tahoma,system-ui,-apple-system,sans-serif}
a{color:var(--brand)}
header{background:var(--card);border-bottom:1px solid var(--line)}
.bar{max-width:1080px;margin:0 auto;padding:12px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.logo{width:36px;height:36px;border-radius:9px}
.title{font-weight:700;font-size:17px}
.sub{color:var(--muted);font-size:12.5px}
.spacer{flex:1}
.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;
padding:2px 10px;font-size:12px;color:var(--muted);background:var(--card);white-space:nowrap}
.pill.brand{border-color:var(--brand);color:var(--brand)}
nav{max-width:1080px;margin:0 auto;padding:0 16px;display:flex;gap:4px;overflow-x:auto}
nav button{border:0;background:none;color:var(--muted);padding:10px 12px;font:inherit;cursor:pointer;
border-bottom:2px solid transparent;white-space:nowrap}
nav button.on{color:var(--brand);border-bottom-color:var(--brand);font-weight:600}
main{max-width:1080px;margin:0 auto;padding:18px 16px 32px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:14px;
box-shadow:var(--shadow)}
h2{font-size:16px;margin:0 0 4px}
h3{font-size:14px;margin:14px 0 6px}
p{margin:6px 0}
.muted{color:var(--muted)}
.small{font-size:12.5px}
.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.btn{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:7px 14px;
font:inherit;cursor:pointer}
.btn:hover{border-color:var(--brand)}
.btn.primary{background:var(--brand);border-color:var(--brand);color:var(--brand-ink);font-weight:600}
.btn.danger{color:var(--bad)}
.btn:disabled{opacity:.55;cursor:default}
input,select{font:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;
padding:6px 9px;min-width:0}
input:focus,select:focus{outline:2px solid var(--soft);border-color:var(--brand)}
label.f{display:flex;flex-direction:column;gap:3px;font-size:12.5px;color:var(--muted)}
.mono,.ltr{direction:ltr;unicode-bidi:isolate}
.mono{font-family:var(--mono);font-size:12.5px}
.weight{font:600 44px/1.1 var(--mono);direction:ltr;text-align:center;margin:8px 0 2px;letter-spacing:.5px}
.weight small{font-size:18px;color:var(--muted);margin-inline-start:6px}
.state{display:flex;justify-content:center;gap:8px;align-items:center;font-size:12.5px;color:var(--muted)}
.dot{width:10px;height:10px;border-radius:50%;background:var(--line);display:inline-block}
.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}.dot.bad{background:var(--bad)}
.kv{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px;font-size:13px}
.kv b{font-weight:600;color:var(--muted)}
.progress{height:8px;background:var(--line);border-radius:99px;overflow:hidden}
.progress>div{height:100%;background:var(--brand);width:0;transition:width .3s}
.log{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:8px 10px;max-height:240px;
overflow:auto;font-size:12.5px}
.log div{padding:1px 0;border-bottom:1px dashed var(--line)}
.log .found{color:var(--ok);font-weight:600}.log .bad{color:var(--bad)}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:start;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--muted);font-weight:600;font-size:12px}
.tag{display:inline-block;padding:0 8px;border-radius:99px;font-size:11.5px;background:var(--soft);color:var(--brand)}
.tag.warn{background:rgba(180,83,9,.12);color:var(--warn)}
.note{border-inline-start:3px solid var(--brand);background:var(--soft);padding:8px 12px;border-radius:6px;margin:8px 0}
.note.warn{border-color:var(--warn);background:rgba(180,83,9,.08)}
.steps{margin:0;padding-inline-start:20px}.steps li{margin:6px 0}
.empty{text-align:center;padding:28px 12px;color:var(--muted)}
.hidden{display:none!important}
.check{display:flex;gap:8px;align-items:flex-start;padding:6px 0;border-bottom:1px solid var(--line)}
.check:last-child{border-bottom:0}
.wrap{overflow-wrap:anywhere}
footer{border-top:1px solid var(--line);background:var(--card)}
footer .bar{font-size:12.5px;color:var(--muted);justify-content:center;text-align:center}
footer a{text-decoration:none}
h3 .num{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:50%;background:var(--soft);
color:var(--brand);font-size:12px;margin-inline-end:8px;vertical-align:1px}
html[dir=rtl] .kv>.mono,html[dir=rtl] .kv>.ltr{text-align:right}
.toast{position:fixed;inset-inline:0;bottom:18px;display:flex;justify-content:center;pointer-events:none}
.toast div{background:var(--ink);color:var(--bg);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s}
.toast div.show{opacity:.95}
"""
_BODY = r"""
<header>
<div class="bar">
<img class="logo" src="__ICON__" alt="">
<div>
<div class="title">SyncPulse Scale Bridge <span class="sub ltr" id="ver"></span></div>
<div class="sub" data-t="tagline"></div>
</div>
<div class="spacer"></div>
<span class="pill hidden" id="simPill" data-t="simulation"></span>
<button class="btn" id="langBtn" type="button"></button>
</div>
<nav id="tabs">
<button data-tab="scales" data-t="tab_scales"></button>
<button data-tab="find" data-t="tab_find"></button>
<button data-tab="connect" data-t="tab_connect"></button>
<button data-tab="manual" data-t="tab_manual"></button>
<button data-tab="protocols" data-t="tab_protocols"></button>
<button data-tab="about" data-t="tab_about"></button>
</nav>
</header>
<main>
<section id="p-scales"></section>
<section id="p-find" class="hidden"></section>
<section id="p-connect" class="hidden"></section>
<section id="p-manual" class="hidden"></section>
<section id="p-protocols" class="hidden"></section>
<section id="p-about" class="hidden"></section>
</main>
<footer><div class="bar" id="sign"></div></footer>
<div class="toast"><div id="toast"></div></div>
"""
_SCRIPT = r"""
const ABOUT = __ABOUT__;
const T = {
en:{
tagline:'Finds the scale on any COM port, shows the live weight and serves it to your network.',
simulation:'Simulation', lang:'فارسی',
tab_scales:'Scales', tab_find:'Find my scale', tab_connect:'Network access', tab_manual:'Manual & monitor',
tab_protocols:'Protocols', tab_about:'About',
no_scales:'No scale saved yet.', no_scales_hint:'Plug the scale into this PC and let the program find it.',
find_now:'Find my scale', stable:'stable', moving:'moving', no_data:'no data', stale:'old reading',
overload:'overload', delete:'Delete', confirm_delete:'Delete the profile "{name}"?',
net:'net', gross:'gross', age:'{ms} ms ago', copied:'Copied', copy:'Copy', address_for:'Address for other systems',
find_title:'Find the scale automatically',
find_intro:'The program listens to each port at every common speed, then asks the scale in the languages of the major manufacturers. Only read-only requests are sent: it never zeroes, tares or prints.',
ports:'COM ports', no_ports:'No COM port on this PC. Plug in the USB-serial adapter and check its driver in Device Manager.',
refresh:'Refresh', thorough:'Thorough search (more speeds and framings, slower)',
wait_print:'The scale sends only when Print is pressed (e.g. wired to a label printer) — wait for me to press it',
listen_only:'Listen only — never send anything to the scale',
log_press_print:'{port}: ▶ press PRINT on the scale now — listening at {baud} for {seconds} s',
log_print_wrong_speed:'{port}: data at {baud} was not readable — press PRINT again (trying the next speed)',
log_print_timeout:'{port}: nothing arrived while waiting for Print',
mode_print:'sends when Print is pressed', printed:'printed {ago}', last_labels:'Last printed labels',
listening_all_day:'Listening all the time: every printed label is recorded.',
ago_s:'{n} s ago', ago_m:'{n} min ago', ago_h:'{n} h ago', no_labels:'No label printed yet.',
start:'Start search', cancel:'Cancel', running:'Searching…', results:'Results',
found_on:'Scale found on {port}', not_found_on:'No scale recognised on {port}', confidence:'confidence',
protocol:'Protocol', line:'Line', mode:'Mode', reading:'Reading', sample:'What arrived',
mode_continuous:'sends by itself', mode_poll:'answers a request', save_as:'Save as', label:'Description',
save:'Save', saved:'Saved', field_tested:'tested on real hardware',
heard_unknown:'Readable data arrived at {baud} {framing} but the format is not in the library yet:',
tips_title:'If nothing is found',
tip1:'Serial output must be switched on in the indicator\'s menu (often called COM, rS, SEr, Serial, Continuous or On request).',
tip2:'Check the cable: many indicators need a null-modem (crossed) cable, some a straight one. Pins 2, 3 and 5 are enough.',
tip3:'Put something on the scale or press its Print key while searching — some indicators only send then.',
tip4:'Try the thorough search, or use “Manual & monitor” to watch the raw bytes.',
ch340:'Driver warning', bt:'Bluetooth — skipped unless ticked',
log_port_start:'Searching {port}…', log_listen:'{port}: listening at {baud} {framing}',
log_heard:'{port}: {bytes} bytes at {baud} ({readable}% readable)',
log_seven_bit:'{port}: looks like 7 data bits with parity — listening again as 7E1/7O1',
log_ask:'{port}: asking {protocol} at {baud} {framing}', log_reply:'{port}: reply to {protocol}: {text}',
log_found:'✔ {port}: {protocol} at {baud} {framing} — {value} {unit}', log_not_found:'{port}: nothing recognised',
log_open_failed:'{port}: cannot open — {error}', log_done:'Finished: {found} scale(s) found',
log_error:'Error: {error}',
conn_title:'Make the scales on this PC available on the network', conn_intro:'Other systems — your server, an ERP, a script — read the scales on this PC over the network. Four things make that work:',
step_address:'The address of this PC', step_key:'The access key', step_firewall:'An open port in Windows Firewall',
step_autostart:'Start with Windows (optional, recommended)',
port_label:'Port', addresses:'Addresses', key_hint:'Only systems that have this key can read the scales. Keep it private.',
show:'Show', hide:'Hide', new_key:'New key', confirm_key:'Make a new key? Systems using the old key will stop working until you enter the new one.',
fw_what:'Windows Firewall blocks incoming connections by default, so other machines cannot reach port {port} on this PC until a rule allows it. The button below asks Windows for administrator permission (a UAC prompt) and adds the inbound rule “SyncPulse Scale Bridge” for TCP port {port} on all network types. It also removes any block rule Windows added for this program if its “Allow access?” question was cancelled.',
fw_open:'Open port {port} in the firewall', fw_remove:'Remove the rule', fw_is_open:'Port {port} is open in Windows Firewall.',
fw_is_closed:'Port {port} is not open yet.', fw_only_from:'Allow only from (server IP, optional)',
fw_manual:'To do it by hand, run in an administrator Command Prompt:',
fw_not_windows:'Not on Windows — open TCP port {port} in this system\'s firewall.',
as_what:'Runs the bridge in the background when the PC starts, before anyone logs in, and restarts it if it stops. Needs administrator permission once.',
as_on:'Starts with Windows.', as_off:'Does not start with Windows.', as_other:'A task exists but points to another copy of the program.',
as_enable:'Start with Windows', as_disable:'Stop starting with Windows',
as_note:'After enabling it, close this window and restart the PC (or keep this window open until then). Opening the program again later shows this page for the background copy.',
in_syncpulse:'Then, in the system that needs the weight', in_syncpulse_steps:'Send this request with the key above. The answer is JSON — value, unit, stable, age_ms … (documentation on GitHub):',
allowed_title:'Allowed clients', allowed_hint:'Leave empty to accept any machine that has the key, or list the IP addresses of the machines that may read the scales (comma separated).',
config_file:'Settings file', cancelled_uac:'Cancelled at the Windows prompt.', failed:'Failed: {error}', done:'Done',
manual_title:'Manual settings and raw monitor', manual_intro:'Watch exactly what arrives on a port with the settings you choose. Useful for an unknown indicator, a cable test, or to check a setting.',
baud:'Speed (baud)', framing:'Framing', request:'Request to send (hex, empty = only listen)', poll:'Every (ms)',
preset:'Fill the request from a protocol', monitor_start:'Start monitor', monitor_stop:'Stop',
frames:'Frames', guesses:'Protocols that can read this', none_yet:'Nothing received yet.', open_error:'Port error: {error}',
save_manual:'Save these settings as a profile', unit_if_missing:'Unit when the scale sends none', output_unit:'Convert to',
as_sent:'as sent', stable_samples:'Equal readings = stable',
proto_title:'Protocol library', proto_intro:'Formats the program can read. Tested on real hardware are marked; the others follow the manufacturers\' published formats and are covered by tests with their sample frames. The same data format is used by many other brands.',
families:'Used by', request_col:'Request', kind:'Type', reference:'Reference',
kind_continuous:'continuous', kind_poll:'on request', kind_handshake:'handshake', kind_print:'on Print key',
about_title:'About', about_text:'SyncPulse Scale Bridge connects weighing scales with an RS-232 serial port to computers and networks. Copy the single program file to the PC beside the scale, run it, and it finds the port, the speed and the protocol by itself, shows the live weight and serves it on the local network.',
about_platform:'Free software that works with any system through its simple HTTP/JSON interface. Originally developed for the SyncPulse platform.',
author:'Author', company:'Company', website:'Website', license:'License', source:'Source code', version:'Version',
license_text:'Free software under the GNU General Public License v3 or later, with an additional term (section 7(b)): the author attribution shown in the program must be kept in copies and modified versions.',
made_by:'Made by', err_name:'Choose a name: letters, digits, - or _.', already_saved:'saved as {names}'
},
fa:{
tagline:'باسکول را روی هر درگاه COM پیدا می‌کند، وزن زنده را نشان می‌دهد و آن را روی شبکه در اختیار می‌گذارد.',
simulation:'شبیه‌ساز', lang:'English',
tab_scales:'باسکول‌ها', tab_find:'پیدا کردن باسکول', tab_connect:'دسترسی از شبکه', tab_manual:'تنظیم دستی و مانیتور',
tab_protocols:'پروتکل‌ها', tab_about:'درباره',
no_scales:'هنوز باسکولی ذخیره نشده است.', no_scales_hint:'باسکول را به این رایانه وصل کنید تا برنامه آن را پیدا کند.',
find_now:'پیدا کردن باسکول', stable:'پایدار', moving:'در حال تغییر', no_data:'بدون داده', stale:'خوانش قدیمی',
overload:'بیش از ظرفیت', delete:'حذف', confirm_delete:'پروفایل «{name}» حذف شود؟',
net:'خالص', gross:'ناخالص', age:'{ms} میلی‌ثانیه پیش', copied:'کپی شد', copy:'کپی', address_for:'نشانی برای سامانه‌های دیگر',
find_title:'پیدا کردن خودکار باسکول',
find_intro:'برنامه روی هر درگاه با همه‌ی سرعت‌های رایج گوش می‌دهد و سپس با زبان سازنده‌های اصلی از باسکول وزن می‌خواهد. فقط درخواست‌های خواندنی فرستاده می‌شود: هیچ‌وقت صفر، تار یا چاپ نمی‌کند.',
ports:'درگاه‌های COM', no_ports:'درگاه COM روی این رایانه نیست. مبدل USB به سریال را وصل کنید و درایور آن را در Device Manager بررسی کنید.',
refresh:'بازخوانی', thorough:'جستجوی کامل (سرعت‌ها و قالب‌های بیشتر، کندتر)',
wait_print:'باسکول فقط با زدن کلید چاپ داده می‌فرستد (مثلاً وصل به لیبل‌زن) — منتظر بمان تا چاپ بزنم',
listen_only:'فقط گوش بده — هیچ چیزی به باسکول فرستاده نشود',
log_press_print:'{port}: ▶ همین حالا کلید چاپ باسکول را بزنید — گوش دادن با {baud} تا {seconds} ثانیه',
log_print_wrong_speed:'{port}: داده با سرعت {baud} خوانا نبود — دوباره چاپ بزنید (سرعت بعدی)',
log_print_timeout:'{port}: در زمان انتظار برای چاپ چیزی نرسید',
mode_print:'با زدن کلید چاپ می‌فرستد', printed:'چاپ‌شده {ago}', last_labels:'آخرین برچسب‌های چاپ‌شده',
listening_all_day:'همیشه در حال گوش دادن: هر برچسبی که چاپ شود ثبت می‌شود.',
ago_s:'{n} ثانیه پیش', ago_m:'{n} دقیقه پیش', ago_h:'{n} ساعت پیش', no_labels:'هنوز برچسبی چاپ نشده است.',
start:'شروع جستجو', cancel:'لغو', running:'در حال جستجو…', results:'نتیجه',
found_on:'باسکول روی {port} پیدا شد', not_found_on:'روی {port} باسکولی شناخته نشد', confidence:'اطمینان',
protocol:'پروتکل', line:'تنظیم خط', mode:'روش', reading:'خوانش', sample:'داده‌ی دریافتی',
mode_continuous:'خودش پیوسته می‌فرستد', mode_poll:'به درخواست پاسخ می‌دهد', save_as:'ذخیره با نام', label:'توضیح',
save:'ذخیره', saved:'ذخیره شد', field_tested:'روی دستگاه واقعی آزموده شده',
heard_unknown:'با {baud} {framing} داده‌ی خوانا رسید اما این قالب هنوز در کتابخانه نیست:',
tips_title:'اگر چیزی پیدا نشد',
tip1:'خروجی سریال باید در منوی نمایشگر روشن باشد (معمولاً با نام COM، rS، SEr، Serial، Continuous یا On request).',
tip2:'کابل را بررسی کنید: بسیاری از نمایشگرها کابل نال‌مودم (ضربدری) و برخی کابل مستقیم می‌خواهند. پایه‌های ۲، ۳ و ۵ کافی است.',
tip3:'هنگام جستجو چیزی روی باسکول بگذارید یا کلید چاپ را بزنید؛ برخی نمایشگرها فقط در آن لحظه می‌فرستند.',
tip4:'جستجوی کامل را امتحان کنید یا از «تنظیم دستی و مانیتور» بایت‌های خام را ببینید.',
ch340:'هشدار درایور', bt:'بلوتوث — مگر تیک بخورد، بررسی نمی‌شود',
log_port_start:'جستجو در {port}…', log_listen:'{port}: گوش دادن با {baud} {framing}',
log_heard:'{port}: {bytes} بایت با سرعت {baud} ({readable}٪ خوانا)',
log_seven_bit:'{port}: به‌نظر ۷ بیت داده با پریتی است — دوباره با 7E1/7O1 گوش می‌دهد',
log_ask:'{port}: پرسش {protocol} با {baud} {framing}', log_reply:'{port}: پاسخ به {protocol}: {text}',
log_found:'✔ {port}: {protocol} با {baud} {framing} — {value} {unit}', log_not_found:'{port}: چیزی شناخته نشد',
log_open_failed:'{port}: باز نمی‌شود — {error}', log_done:'پایان: {found} باسکول پیدا شد',
log_error:'خطا: {error}',
conn_title:'در دسترس گذاشتن باسکول‌های این رایانه روی شبکه', conn_intro:'سامانه‌های دیگر — سرور شما، ERP، یک اسکریپت — از راه شبکه باسکول‌های این رایانه را می‌خوانند. برای این کار چهار چیز لازم است:',
step_address:'نشانی این رایانه', step_key:'کلید دسترسی', step_firewall:'باز بودن درگاه در فایروال ویندوز',
step_autostart:'اجرا همراه ویندوز (اختیاری، پیشنهادی)',
port_label:'درگاه', addresses:'نشانی‌ها', key_hint:'فقط سامانه‌هایی که این کلید را دارند می‌توانند باسکول‌ها را بخوانند. آن را محرمانه نگه دارید.',
show:'نمایش', hide:'پنهان', new_key:'کلید تازه', confirm_key:'کلید تازه ساخته شود؟ سامانه‌هایی که کلید قبلی را دارند تا کلید تازه را وارد نکنید کار نمی‌کنند.',
fw_what:'فایروال ویندوز به‌طور پیش‌فرض اتصال‌های ورودی را می‌بندد؛ بنابراین تا وقتی قانونی اجازه ندهد، رایانه‌های دیگر به درگاه {port} این رایانه نمی‌رسند. دکمه‌ی زیر از ویندوز اجازه‌ی مدیر (پنجره‌ی UAC) می‌گیرد و قانون ورودی «SyncPulse Scale Bridge» را برای TCP درگاه {port} روی همه‌ی انواع شبکه می‌سازد. اگر پرسش «Allow access؟» ویندوز لغو شده باشد، قانون مسدودکننده‌ای را هم که ویندوز برای این برنامه ساخته پاک می‌کند.',
fw_open:'باز کردن درگاه {port} در فایروال', fw_remove:'حذف قانون', fw_is_open:'درگاه {port} در فایروال ویندوز باز است.',
fw_is_closed:'درگاه {port} هنوز باز نیست.', fw_only_from:'فقط از این نشانی (IP سرور، اختیاری)',
fw_manual:'برای انجام دستی، در Command Prompt با دسترسی مدیر اجرا کنید:',
fw_not_windows:'این سیستم ویندوز نیست — درگاه TCP {port} را در فایروال همین سیستم باز کنید.',
as_what:'پل را هنگام روشن شدن رایانه، پیش از ورود هر کاربر، در پس‌زمینه اجرا می‌کند و اگر بسته شود دوباره راه می‌اندازد. یک‌بار اجازه‌ی مدیر لازم دارد.',
as_on:'همراه ویندوز اجرا می‌شود.', as_off:'همراه ویندوز اجرا نمی‌شود.', as_other:'وظیفه‌ای هست ولی به نسخه‌ی دیگری از برنامه اشاره می‌کند.',
as_enable:'اجرا همراه ویندوز', as_disable:'لغو اجرا همراه ویندوز',
as_note:'پس از فعال کردن، این پنجره را ببندید و رایانه را دوباره راه‌اندازی کنید (یا تا آن زمان این پنجره را باز نگه دارید). اگر بعداً برنامه را باز کنید، همین صفحه برای نسخه‌ی پس‌زمینه باز می‌شود.',
in_syncpulse:'سپس در سامانه‌ای که وزن را لازم دارد', in_syncpulse_steps:'این درخواست را با کلید بالا بفرستید. پاسخ JSON است — value، unit، stable، age_ms … (مستندات در گیت‌هاب):',
allowed_title:'سامانه‌های مجاز', allowed_hint:'خالی بگذارید تا هر دستگاهی که کلید را دارد پذیرفته شود، یا IP رایانه‌هایی را که اجازه‌ی خواندن دارند با ویرگول جدا بنویسید.',
config_file:'فایل تنظیمات', cancelled_uac:'در پنجره‌ی ویندوز لغو شد.', failed:'ناموفق: {error}', done:'انجام شد',
manual_title:'تنظیم دستی و مانیتور خام', manual_intro:'دقیقاً ببینید با تنظیمی که انتخاب می‌کنید چه چیزی روی درگاه می‌رسد. برای نمایشگر ناشناخته، آزمایش کابل یا بررسی یک تنظیم.',
baud:'سرعت (baud)', framing:'قالب', request:'درخواست ارسالی (hex؛ خالی = فقط گوش دادن)', poll:'هر (میلی‌ثانیه)',
preset:'پر کردن درخواست از روی پروتکل', monitor_start:'شروع مانیتور', monitor_stop:'توقف',
frames:'فریم‌ها', guesses:'پروتکل‌هایی که این داده را می‌خوانند', none_yet:'هنوز چیزی نرسیده است.', open_error:'خطای درگاه: {error}',
save_manual:'ذخیره‌ی این تنظیم به‌عنوان پروفایل', unit_if_missing:'واحد، وقتی باسکول واحد نمی‌فرستد', output_unit:'تبدیل به',
as_sent:'همان‌طور که می‌رسد', stable_samples:'تعداد خوانش برابر = پایدار',
proto_title:'کتابخانه‌ی پروتکل‌ها', proto_intro:'قالب‌هایی که برنامه می‌خواند. موارد آزموده روی دستگاه واقعی علامت دارند؛ بقیه بر پایه‌ی قالب‌های منتشرشده‌ی سازندگان‌اند و با فریم‌های نمونه‌ی همان مستندات آزمایش شده‌اند. بسیاری از برندهای دیگر هم از همین قالب‌ها استفاده می‌کنند.',
families:'کاربرد', request_col:'درخواست', kind:'نوع', reference:'مرجع',
kind_continuous:'پیوسته', kind_poll:'با درخواست', kind_handshake:'دست‌دادن', kind_print:'با کلید چاپ',
about_title:'درباره', about_text:'SyncPulse Scale Bridge باسکول‌های دارای درگاه سریال RS-232 را به رایانه و شبکه وصل می‌کند. تنها فایل برنامه را روی رایانه‌ی کنار باسکول کپی و اجرا کنید؛ درگاه، سرعت و پروتکل را خودش پیدا می‌کند، وزن زنده را نشان می‌دهد و روی شبکه‌ی محلی در اختیار می‌گذارد.',
about_platform:'نرم‌افزار آزاد است و با رابط ساده‌ی HTTP/JSON با هر سامانه‌ای کار می‌کند. در اصل برای پلتفرم SyncPulse توسعه داده شد.',
author:'سازنده', company:'شرکت', website:'وب‌سایت', license:'مجوز', source:'کد منبع', version:'نسخه',
license_text:'نرم‌افزار آزاد با مجوز GNU GPL نسخه‌ی ۳ یا بالاتر، با یک شرط افزوده (بند ۷(ب)): نام و مشخصات سازنده که در برنامه نمایش داده می‌شود باید در همه‌ی نسخه‌ها و نسخه‌های تغییر یافته حفظ شود.',
made_by:'ساخته‌ی', err_name:'یک نام انتخاب کنید: حروف انگلیسی، عدد، - یا _.', already_saved:'ذخیره‌شده با نام {names}'
}};
const S = {lang:'en', tab:'scales', state:null, ports:[], protocols:[], job:null, jobSince:0, log:[],
weights:{}, inflight:{}, showKey:false, monitor:null, monitorData:null, busy:false, sel:null, draft:{}, shownResults:-1, shownLog:0, shownRunning:false};
try { S.lang = localStorage.getItem('sb.lang') || ((navigator.language||'').toLowerCase().startsWith('fa') ? 'fa' : 'en'); } catch (e) {}
const $ = (sel, el = document) => el.querySelector(sel);
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function t(key, vars) {
let s = (T[S.lang] && T[S.lang][key]) ?? T.en[key] ?? key;
if (vars) s = s.replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? '');
return s;
}
function toast(msg) {
const el = $('#toast'); el.textContent = msg; el.classList.add('show');
clearTimeout(toast.h); toast.h = setTimeout(() => el.classList.remove('show'), 1800);
}
async function api(path, opts = {}) {
const headers = {'X-Requested-With': 'ScaleBridge'};
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
let data;
try {
const res = await fetch(path, {method: opts.method || (opts.body !== undefined ? 'POST' : 'GET'), headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, cache: 'no-store'});
try { data = await res.json(); } catch (e) { data = {ok: false, error: 'HTTP ' + res.status}; }
if (!res.ok) data.ok = false;
} catch (e) { data = {ok: false, error: String(e.message || e)}; }
return data;
}
async function copy(text) {
try { await navigator.clipboard.writeText(text); } catch (e) {
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch (e2) {} ta.remove();
}
toast(t('copied'));
}
function fmtValue(v) {
if (v === null || v === undefined) return '—';
const n = Number(v); if (!isFinite(n)) return esc(v);
return n.toLocaleString('en-US', {maximumFractionDigits: 6});
}
function protoName(id) { const p = S.protocols.find(x => x.id === id); return p ? p.name : id; }
function slug(s) { return String(s || '').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^[-_]+|[-_]+$/g, '').toLowerCase().slice(0, 40) || 'scale'; }
function lineText(serial) { return serial ? `${serial.baudrate} ${serial.bytesize}${serial.parity}${serial.stopbits}` : ''; }
// ---------------------------------------------------------------- chrome
function applyLang() {
document.documentElement.lang = S.lang;
document.documentElement.dir = S.lang === 'fa' ? 'rtl' : 'ltr';
document.querySelectorAll('[data-t]').forEach(el => { el.textContent = t(el.dataset.t); });
$('#langBtn').textContent = t('lang');
$('#ver').textContent = 'v' + ABOUT.version;
$('#sign').innerHTML = `<span>${esc(t('made_by'))} <b>${esc(ABOUT.author)}</b> · ${esc(ABOUT.company)}</span>
<a href="${esc(ABOUT.website)}" target="_blank" rel="noopener">${esc(ABOUT.website.replace(/^https?:\/\//, ''))}</a>
<a href="${esc(ABOUT.github)}" target="_blank" rel="noopener">GitHub</a>
<a href="${esc(ABOUT.linkedin)}" target="_blank" rel="noopener">LinkedIn</a>
<a href="${esc(ABOUT.repository)}" target="_blank" rel="noopener">${esc(t('source'))}</a>
<span>${esc(ABOUT.license)}</span>`;
renderAll();
}
function setTab(tab) {
S.tab = tab;
document.querySelectorAll('#tabs button').forEach(b => b.classList.toggle('on', b.dataset.tab === tab));
document.querySelectorAll('main > section').forEach(s => s.classList.toggle('hidden', s.id !== 'p-' + tab));
if (tab !== 'manual') stopMonitor();
if (tab === 'find') loadPorts();
if (location.hash !== '#' + tab) history.replaceState(null, '', '#' + tab);
renderAll();
}
function renderAll(keepFind) {
renderScales(); if (keepFind) paintResults(); else renderFind();
renderConnect(); renderManual(); renderProtocols(); renderAbout();
}
// ---------------------------------------------------------------- scales
function renderScales() {
const el = $('#p-scales'); if (!S.state) { el.innerHTML = ''; return; }
const profiles = S.state.profiles || [];
if (!profiles.length) {
el.innerHTML = `<div class="card empty"><h2>${esc(t('no_scales'))}</h2><p>${esc(t('no_scales_hint'))}</p>
<p><button class="btn primary" data-go="find">${esc(t('find_now'))}</button></p></div>`;
return;
}
const ip = (S.state.addresses || [])[0] || '127.0.0.1';
el.innerHTML = '<div class="grid">' + profiles.map(p => `
<div class="card" data-profile="${esc(p.name)}">
<div class="row"><h2 class="wrap">${esc(p.label || p.name)}</h2><div class="spacer"></div>
<span class="tag ltr">${esc(p.port)}</span></div>
<div class="weight" id="w-${esc(p.name)}">—</div>
<div class="state" id="s-${esc(p.name)}"><span class="dot"></span><span>${esc(t('no_data'))}</span></div>
<div class="kv small" id="f-${esc(p.name)}" style="margin-top:8px"></div>
${isPrint(p) ? `<p class="small muted" style="margin:8px 0 0">${esc(t('listening_all_day'))}</p>
<details style="margin-top:6px"><summary class="small">${esc(t('last_labels'))}</summary><div class="small" id="l-${esc(p.name)}"></div></details>` : ''}
<div class="kv small" style="margin-top:10px">
<b>${esc(t('protocol'))}</b><span><bdi>${esc(protoName(p.protocol))}</bdi></span>
<b>${esc(t('line'))}</b><span class="mono">${esc(lineText(p.serial))}</span>
</div>
<div class="small muted" style="margin-top:8px">${esc(t('address_for'))}</div>
<div class="row" style="gap:6px;flex-wrap:nowrap"><span class="mono wrap ltr" style="flex:1">http://${esc(ip)}:${esc(S.state.listen_port)}/weight?profile=${esc(p.name)}</span>
<button class="btn small" style="padding:2px 8px" data-copy="http://${esc(ip)}:${esc(S.state.listen_port)}/weight?profile=${esc(p.name)}">${esc(t('copy'))}</button></div>
<div class="row" style="margin-top:10px"><span class="small muted mono">${esc(p.name)}</span><div class="spacer"></div>
<button class="btn danger" data-del="${esc(p.name)}">${esc(t('delete'))}</button></div>
</div>`).join('') + '</div>';
profiles.forEach(p => paintWeight(p.name));
}
function paintWeight(name) {
const w = S.weights[name]; const vEl = document.getElementById('w-' + name); const sEl = document.getElementById('s-' + name);
if (!vEl || !sEl) return;
if (!w) return;
if (!w.ok) {
vEl.innerHTML = '—';
sEl.innerHTML = `<span class="dot bad"></span><span class="wrap">${esc(w.error || t('no_data'))}</span>`;
return;
}
vEl.innerHTML = w.overload && (w.value === null || w.value === undefined) ? esc(t('overload'))
: `${fmtValue(w.value)}<small>${esc(w.unit || '')}</small>`;
const fEl = document.getElementById('f-' + name);
if (fEl) fEl.innerHTML = Object.entries(w.fields || {}).filter(([k]) => k !== 'barcode')
.map(([k, v]) => `<b class="ltr">${esc(k)}</b><span class="mono">${esc(v)}</span>`).join('');
let cls = 'warn', label = t('moving');
const profile = (S.state.profiles || []).find(p => p.name === name);
if (profile && isPrint(profile)) { cls = 'ok'; label = t('printed', {ago: ago(w.age_ms)}); }
else if (w.stale) { cls = ''; label = t('stale'); } else if (w.stable) { cls = 'ok'; label = t('stable'); }
const extra = w.net === true ? ' · ' + t('net') : (w.net === false ? ' · ' + t('gross') : '');
sEl.innerHTML = `<span class="dot ${cls}"></span><span>${esc(label + extra)}</span>`;
}
function isPrint(p) { const proto = S.protocols.find(x => x.id === p.protocol); return !!(proto && proto.kind === 'print'); }
function ago(ms) {
const s = Math.max(0, Math.round((ms || 0) / 1000));
return s < 90 ? t('ago_s', {n: s}) : (s < 5400 ? t('ago_m', {n: Math.round(s / 60)}) : t('ago_h', {n: Math.round(s / 3600)}));
}
async function paintLabels(name) {
const el = document.getElementById('l-' + name); if (!el) return;
const r = await api('/api/labels?profile=' + encodeURIComponent(name) + '&limit=8');
if (!r.ok) return;
el.innerHTML = r.labels.length ? '<table><tbody>' + r.labels.slice().reverse().map(l => `<tr>
<td class="mono">${esc(l.at.slice(11, 19))}</td><td class="mono"><b>${fmtValue(l.value)}</b> ${esc(l.unit)}</td>
<td class="small">${esc(Object.entries(l.fields || {}).filter(([k]) => !['barcode', 'date', 'time'].includes(k)).map(([k, v]) => k + ': ' + v).join(' · '))}</td></tr>`).join('') + '</tbody></table>'
: `<p class="muted">${esc(t('no_labels'))}</p>`;
}
async function pollWeights() {
if (S.tab === 'scales' && S.state && !document.hidden) {
for (const p of S.state.profiles || []) {
if (S.inflight[p.name]) continue;
S.inflight[p.name] = true;
// A print-only scale is silent between labels: show the last label, however old.
const extra = isPrint(p) ? '&wait_ms=200&max_age_ms=60000000' : '&wait_ms=1500';
api('/api/weight?profile=' + encodeURIComponent(p.name) + extra).then(r => {
S.weights[p.name] = r; paintWeight(p.name);
if (isPrint(p)) return paintLabels(p.name);
}).finally(() => { S.inflight[p.name] = false; });
}
}
setTimeout(pollWeights, 700);
}
// ---------------------------------------------------------------- find
async function loadPorts() {
const r = await api('/api/ports');
if (r.ok) { S.ports = r.ports; renderFind(); }
}
function renderFind() {
const el = $('#p-find'); if (!S.state) return;
const job = S.job; const running = job && job.running;
const checked = S.sel || new Set(S.ports.filter(p => p.kind !== 'bluetooth').map(p => p.device));
const thorough = $('#thorough') ? $('#thorough').checked : false;
const waitPrint = $('#waitPrint') ? $('#waitPrint').checked : false;
const listenOnly = $('#listenOnly') ? $('#listenOnly').checked : false;
const ports = S.ports.length ? S.ports.map(p => `
<label class="check"><input type="checkbox" name="port" value="${esc(p.device)}" ${checked.has(p.device) ? 'checked' : ''} ${running ? 'disabled' : ''}>
<span><b class="ltr">${esc(p.device)}</b> <span class="muted">${esc(p.description || '')}</span>
${p.kind === 'bluetooth' ? `<br><span class="tag warn">${esc(t('bt'))}</span>` : ''}
${p.note ? `<br><span class="tag warn">${esc(t('ch340'))}</span> <span class="small muted">${esc(p.note)}</span>` : ''}</span></label>`).join('')
: `<p class="note warn">${esc(t('no_ports'))}</p>`;
el.innerHTML = `
<div class="card"><h2>${esc(t('find_title'))}</h2><p class="muted">${esc(t('find_intro'))}</p>
<h3>${esc(t('ports'))}</h3>${ports}
<div class="row" style="margin-top:10px">
<label class="row small"><input type="checkbox" id="thorough" ${thorough ? 'checked' : ''} ${running ? 'disabled' : ''}> ${esc(t('thorough'))}</label>
<label class="row small"><input type="checkbox" id="waitPrint" ${waitPrint ? 'checked' : ''} ${running ? 'disabled' : ''}> ${esc(t('wait_print'))}</label>
<label class="row small"><input type="checkbox" id="listenOnly" ${listenOnly ? 'checked' : ''} ${running ? 'disabled' : ''}> ${esc(t('listen_only'))}</label>
<div class="spacer"></div>
<button class="btn" id="refreshPorts" ${running ? 'disabled' : ''}>${esc(t('refresh'))}</button>
${running ? `<button class="btn danger" id="cancelDetect">${esc(t('cancel'))}</button>`
: `<button class="btn primary" id="startDetect" ${S.ports.length ? '' : 'disabled'}>${esc(t('start'))}</button>`}
</div>
<div id="findStatus"></div>
</div>
<div id="findResults"></div>`;
S.shownResults = -1; S.shownRunning = running;
paintProgress(); paintResults();
}
function logLine(e) {
const cls = e.code === 'found' ? 'found' : (e.code === 'open_failed' || e.code === 'error' ? 'bad' : '');
const vars = {...e, protocol: e.protocol ? protoName(e.protocol) : '', readable: e.readable !== undefined ? Math.round(e.readable * 100) : ''};
return `<div class="${cls}"><span class="muted mono">${esc(Number(e.t).toFixed(1))}s</span> ${esc(t('log_' + e.code, vars))}</div>`;
}
function paintProgress() {
const box = $('#findStatus'); const job = S.job; if (!box) return;
if (!job) { box.innerHTML = ''; return; }
const pct = Math.round((job.progress || 0) * 100);
if (!$('#log')) {
box.innerHTML = `<div style="margin-top:12px"><div class="row small muted"><span id="jobText"></span><div class="spacer"></div><span class="mono" id="jobPct"></span></div>
<div class="progress"><div id="jobBar"></div></div><div class="log" id="log" style="margin-top:8px"></div></div>`;
S.shownLog = 0;
}
$('#jobText').textContent = job.running ? t('running') : t('done');
$('#jobPct').textContent = `${pct}% · ${job.elapsed_s}s`;
$('#jobBar').style.width = pct + '%';
const logEl = $('#log');
const start = Math.max(S.shownLog || 0, S.log.length - 250);
if (start < S.log.length) {
logEl.insertAdjacentHTML('beforeend', S.log.slice(start).map(logLine).join(''));
S.shownLog = S.log.length;
while (logEl.childElementCount > 250) logEl.firstElementChild.remove();
logEl.scrollTop = logEl.scrollHeight;
}
}
function paintResults() {
const box = $('#findResults'); const job = S.job; if (!box) return;
const results = job ? (job.results || []) : [];
const key = results.length + ':' + (job && job.running ? 1 : 0) + ':' + (S.state.profiles || []).length;
if (S.shownResults === key) return;
S.shownResults = key;
box.innerHTML = (results.length ? `<h3>${esc(t('results'))}</h3>${results.map(renderResult).join('')}` : '')
+ (job && !job.running && !results.some(r => r.found) ? tipsCard() : '');
}
function tipsCard() {
return `<div class="card"><h3>${esc(t('tips_title'))}</h3><ul class="steps">
<li>${esc(t('tip1'))}</li><li>${esc(t('tip2'))}</li><li>${esc(t('tip3'))}</li><li>${esc(t('tip4'))}</li></ul></div>`;
}
function renderResult(r) {
if (!r.found) {
const u = r.unknown;
return `<div class="card"><h2>${esc(t('not_found_on', {port: r.port}))}</h2>
${r.error ? `<p class="note warn">${esc(r.error)}</p>` : ''}
${u ? `<p class="muted">${esc(t('heard_unknown', {baud: u.baud, framing: u.framing}))}</p>
<div class="log mono">${esc(u.sample_text)}</div><div class="log mono small" style="margin-top:6px">${esc(u.sample_hex)}</div>` : ''}</div>`;
}
const rd = r.reading || {};
const d = S.draft[r.port] || {};
const same = (S.state.profiles || []).filter(p => p.port === r.port && p.protocol === r.protocol).map(p => p.name);
return `<div class="card">
<div class="row"><h2>${esc(t('found_on', {port: r.port}))}</h2><div class="spacer"></div>
<span class="tag">${esc(t('confidence'))} ${Math.round((r.confidence || 0) * 100)}%</span>
${r.field_tested ? `<span class="tag">${esc(t('field_tested'))}</span>` : ''}</div>
<div class="kv" style="margin-top:8px">
<b>${esc(t('protocol'))}</b><span><bdi>${esc(r.protocol_name)}</bdi></span>
<b>${esc(t('line'))}</b><span class="mono">${esc(r.port)} · ${esc(lineText(r.serial))}${r.exchange && r.exchange.request_hex ? ' · ' + esc(r.exchange.request_hex) : ''}</span>
<b>${esc(t('mode'))}</b><span>${esc(t('mode_' + r.mode))}</span>
<b>${esc(t('reading'))}</b><span class="mono">${fmtValue(rd.value)} ${esc(rd.unit || '')}${rd.stable === true ? ' · ' + esc(t('stable')) : (rd.stable === false ? ' · ' + esc(t('moving')) : '')}</span>
<b>${esc(t('sample'))}</b><span class="mono wrap">${esc(r.sample_text)}</span>
</div>
<div class="row" style="margin-top:12px">
<label class="f">${esc(t('save_as'))}<input class="ltr" data-name="${esc(r.port)}" value="${esc(d.name ?? slug('scale-' + r.port))}" size="18"></label>
<label class="f">${esc(t('label'))}<input data-label="${esc(r.port)}" value="${esc(d.label ?? '')}" size="22"></label>
${rd.unit ? '' : `<label class="f">${esc(t('unit_if_missing'))}<select data-unit="${esc(r.port)}">${['kg','g','t','lb'].map(u => `<option ${d.unit === u ? 'selected' : ''}>${u}</option>`).join('')}</select></label>`}
<div class="spacer"></div>
${same.length ? `<span class="tag">${esc(t('already_saved', {names: same.join(', ')}))}</span>` : ''}
<button class="btn primary" data-save-result="${esc(r.port)}">${esc(t('save'))}</button>
</div></div>`;
}
async function startDetect() {
const ports = [...document.querySelectorAll('input[name=port]:checked')].map(i => i.value);
if (!ports.length) return;
S.log = []; S.jobSince = 0; S.draft = {};
const r = await api('/api/detect', {body: {ports, thorough: $('#thorough').checked, bluetooth: true,
print_wait_s: $('#waitPrint').checked ? 60 : 0, listen_only: $('#listenOnly').checked}});
if (!r.ok) { toast(t('failed', {error: r.error})); return; }
S.job = r.job; renderFind(); setTimeout(pollDetect, 300);
}
async function pollDetect() {
const r = await api('/api/detect?since=' + S.jobSince);
if (r.ok && r.job) {
S.log.push(...r.job.log); S.jobSince = r.job.log_size; S.job = r.job;
if (S.shownRunning !== r.job.running) renderFind(); else { paintProgress(); paintResults(); }
if (r.job.running) { setTimeout(pollDetect, 500); } else { refreshState(); }
}
}
async function saveResult(port) {
const r = (S.job.results || []).find(x => x.port === port); if (!r) return;
const name = document.querySelector(`[data-name="${CSS.escape(port)}"]`).value.trim();
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/.test(name)) { toast(t('err_name')); return; }
const label = document.querySelector(`[data-label="${CSS.escape(port)}"]`).value.trim();
const unitEl = document.querySelector(`[data-unit="${CSS.escape(port)}"]`);
const res = await api('/api/profiles', {body: {detection: r, name, label, unit: unitEl ? unitEl.value : ''}});
if (!res.ok) { toast(t('failed', {error: res.error})); return; }
S.state.profiles = res.profiles; toast(t('saved')); renderAll();
}
// ---------------------------------------------------------------- connect
function renderConnect() {
const el = $('#p-connect'); const st = S.state; if (!st) return;
const port = st.listen_port; const fw = st.firewall || {}; const as = st.autostart || {};
const key = S.showKey ? st.token : '•'.repeat(Math.min(24, (st.token || '').length));
const addr = (st.addresses || []).map(a => `<span class="tag ltr">${esc(a)}</span>`).join(' ') || '<span class="muted">—</span>';
const fwCmd = `netsh advfirewall firewall add rule name="SyncPulse Scale Bridge" dir=in action=allow protocol=TCP localport=${port} profile=any`;
el.innerHTML = `
<div class="card"><h2>${esc(t('conn_title'))}</h2><p class="muted">${esc(t('conn_intro'))}</p></div>
<div class="card"><h3><span class="num">1</span>${esc(t('step_address'))}</h3>
<div class="kv"><b>${esc(t('addresses'))}</b><span>${addr}</span><b>${esc(t('port_label'))}</b><span class="mono">${esc(port)}</span>
<b>Host</b><span class="mono">${esc(st.host)}</span></div></div>
<div class="card"><h3><span class="num">2</span>${esc(t('step_key'))}</h3>
<div class="row"><span class="mono wrap" style="font-size:14px">${esc(key)}</span><div class="spacer"></div>
<button class="btn" id="keyShow">${esc(S.showKey ? t('hide') : t('show'))}</button>
<button class="btn" id="keyCopy">${esc(t('copy'))}</button>
<button class="btn danger" id="keyNew">${esc(t('new_key'))}</button></div>
<p class="small muted">${esc(t('key_hint'))}</p></div>
<div class="card"><h3><span class="num">3</span>${esc(t('step_firewall'))}</h3>
${fw.supported === false ? `<p>${esc(t('fw_not_windows', {port}))}</p>` : `
<p class="${fw.port_ok ? 'note' : 'note warn'}"><span class="dot ${fw.port_ok ? 'ok' : 'warn'}"></span> ${esc(fw.port_ok ? t('fw_is_open', {port}) : t('fw_is_closed', {port}))}</p>
<p class="small">${esc(t('fw_what', {port}))}</p>
<div class="row"><label class="f">${esc(t('fw_only_from'))}<input class="ltr" id="fwRemote" placeholder="192.168.1.10" size="16"></label>
<div class="spacer"></div>
${fw.exists ? `<button class="btn" id="fwRemove">${esc(t('fw_remove'))}</button>` : ''}
<button class="btn primary" id="fwOpen">${esc(t('fw_open', {port}))}</button></div>
<p class="small muted" style="margin-top:10px">${esc(t('fw_manual'))}</p><div class="log mono small">${esc(fwCmd)}</div>`}
</div>
<div class="card"><h3><span class="num">4</span>${esc(t('step_autostart'))}</h3>
${as.supported === false ? '' : `
<p class="${as.enabled && as.this_program ? 'note' : 'note warn'}"><span class="dot ${as.enabled ? (as.this_program ? 'ok' : 'warn') : ''}"></span>
${esc(as.enabled ? (as.this_program ? t('as_on') : t('as_other')) : t('as_off'))}</p>
<p class="small">${esc(t('as_what'))}</p>
<div class="row"><div class="spacer"></div>
${as.enabled ? `<button class="btn" id="asOff">${esc(t('as_disable'))}</button>` : ''}
${!as.enabled || !as.this_program ? `<button class="btn primary" id="asOn">${esc(t('as_enable'))}</button>` : ''}</div>
<p class="small muted">${esc(t('as_note'))}</p>`}
</div>
<div class="card"><h3>${esc(t('in_syncpulse'))}</h3><p>${esc(t('in_syncpulse_steps', {port}))}</p>
<div class="log mono ltr" style="text-align:left">GET http://${esc((st.addresses || [])[0] || '127.0.0.1')}:${esc(port)}/weight?profile=&lt;name&gt;<br>X-Bridge-Token: &lt;key&gt;</div></div>
<div class="card"><h3>${esc(t('allowed_title'))}</h3><p class="small muted">${esc(t('allowed_hint'))}</p>
<div class="row"><input class="ltr" id="allowed" style="flex:1" value="${esc((st.allowed_clients || []).join(', '))}">
<button class="btn" id="allowedSave">${esc(t('save'))}</button></div>
<p class="small muted" style="margin-top:10px">${esc(t('config_file'))}: <span class="mono wrap">${esc(st.config_path)}</span></p></div>`;
}
async function adminAction(path, body) {
S.busy = true; document.body.style.cursor = 'progress';
const r = await api(path, {body});
S.busy = false; document.body.style.cursor = '';
if (r.state) S.state = r.state;
toast(r.ok ? t('done') : (r.cancelled ? t('cancelled_uac') : t('failed', {error: r.error || ('code ' + r.code)})));
renderAll();
}
// ---------------------------------------------------------------- manual
function renderManual() {
const el = $('#p-manual'); if (!S.state) return;
if (el.dataset.ready === S.lang + S.ports.length + S.protocols.length) { paintMonitor(); return; }
const keep = {};
el.querySelectorAll('[id^=m-]').forEach(i => { keep[i.id] = i.value; });
const bauds = [1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200];
const framings = ['8N1', '7E1', '7O1', '8E1', '8O1', '8N2', '7N2'];
el.innerHTML = `
<div class="card"><h2>${esc(t('manual_title'))}</h2><p class="muted">${esc(t('manual_intro'))}</p>
<div class="row">
<label class="f">${esc(t('port_label'))}<select id="m-port">${S.ports.map(p => `<option value="${esc(p.device)}">${esc(p.device)}</option>`).join('')}</select></label>
<label class="f">${esc(t('baud'))}<select id="m-baud">${bauds.map(b => `<option ${b === 9600 ? 'selected' : ''}>${b}</option>`).join('')}</select></label>
<label class="f">${esc(t('framing'))}<select id="m-framing">${framings.map(f => `<option>${f}</option>`).join('')}</select></label>
<label class="f">${esc(t('preset'))}<select id="m-proto"><option value="">—</option>${S.protocols.filter(p => !p.id.startsWith('custom')).map(p => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('')}</select></label>
</div>
<div class="row" style="margin-top:8px">
<label class="f" style="flex:1">${esc(t('request'))}<input class="ltr mono" id="m-request" placeholder="53 49 0D 0A"></label>
<label class="f">${esc(t('poll'))}<input class="ltr" id="m-poll" type="number" min="100" max="10000" step="100" value="500" style="width:90px"></label>
<button class="btn primary" id="monBtn"></button>
</div>
<div id="monOut" style="margin-top:12px"></div>
</div>
<div class="card"><h3>${esc(t('save_manual'))}</h3>
<div class="row">
<label class="f">${esc(t('save_as'))}<input class="ltr" id="m-name" value="scale-1" size="16"></label>
<label class="f">${esc(t('label'))}<input id="m-label" size="20"></label>
<label class="f">${esc(t('protocol'))}<select id="m-sproto">${S.protocols.map(p => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('')}</select></label>
<label class="f">${esc(t('unit_if_missing'))}<select id="m-unit"><option value="">—</option>${['kg','g','t','lb','oz'].map(u => `<option>${u}</option>`).join('')}</select></label>
<label class="f">${esc(t('output_unit'))}<select id="m-ounit"><option value="">${esc(t('as_sent'))}</option>${['kg','g','t','lb','oz'].map(u => `<option>${u}</option>`).join('')}</select></label>
<label class="f">${esc(t('stable_samples'))}<input class="ltr" id="m-stable" type="number" min="1" max="20" value="3" style="width:70px"></label>
</div>
<div class="row" id="m-custom" style="margin-top:8px"></div>
<div class="row" style="margin-top:8px"><div class="spacer"></div><button class="btn primary" id="m-save">${esc(t('save'))}</button></div>
</div>`;
Object.entries(keep).forEach(([id, v]) => { const i = document.getElementById(id); if (i && v !== undefined) i.value = v; });
el.dataset.ready = S.lang + S.ports.length + S.protocols.length;
renderCustom(); paintMonitor();
}
function renderCustom() {
const box = $('#m-custom'); if (!box) return;
const id = $('#m-sproto').value;
if (id === 'custom_binary') {
box.innerHTML = `<label class="f">pattern (hex, ?? = any)<input class="ltr mono" id="c-pattern" value="58 ?? 07 07"></label>
<label class="f">value_offset<input class="ltr" id="c-offset" type="number" value="4" style="width:70px"></label>
<label class="f">value_length<input class="ltr" id="c-length" type="number" value="3" style="width:70px"></label>
<label class="f">byte_order<select id="c-order"><option>big</option><option>little</option></select></label>
<label class="row small"><input type="checkbox" id="c-signed"> signed</label>`;
} else if (id === 'custom_ascii') {
box.innerHTML = `<label class="f" style="flex:1">regex (group 1 = number)<input class="ltr mono" id="c-regex" value="([-+]?\\d+\\.?\\d*)"></label>`;
} else box.innerHTML = '';
}
function monitorQuery() {
const q = new URLSearchParams({port: $('#m-port').value, baud: $('#m-baud').value, framing: $('#m-framing').value,
request_hex: $('#m-request').value, poll_ms: $('#m-poll').value || '500'});
const proto = S.protocols.find(p => p.id === $('#m-proto').value);
if (proto && proto.after_ack_hex) q.set('after_ack_hex', proto.after_ack_hex);
return q;
}
function startMonitor() {
stopMonitor(); S.monitorData = null;
const tick = async () => {
const r = await api('/api/monitor?' + monitorQuery());
if (!S.monitor) return;
S.monitorData = r; paintMonitor();
S.monitor = setTimeout(tick, 1000);
};
S.monitor = setTimeout(tick, 0); paintMonitor();
}
function stopMonitor() { if (S.monitor) { clearTimeout(S.monitor); S.monitor = null; paintMonitor(); } }
function paintMonitor() {
const btn = $('#monBtn'); const out = $('#monOut'); if (!btn || !out) return;
btn.textContent = S.monitor ? t('monitor_stop') : t('monitor_start');
const r = S.monitorData; if (!r) { out.innerHTML = ''; return; }
if (!r.ok && r.error) { out.innerHTML = `<p class="note warn">${esc(t('open_error', {error: r.error}))}</p>`; return; }
const frames = (r.frames || []).slice().reverse();
out.innerHTML = `<div class="small muted mono">${esc(r.settings || '')} · rx ${esc((r.stats || {}).bytes_rx ?? 0)} B</div>
${frames.length ? `<table style="margin-top:6px"><thead><tr><th>ms</th><th>HEX</th><th>TEXT</th></tr></thead><tbody>
${frames.map(f => `<tr><td class="mono">${esc(f.age_ms)}</td><td class="mono wrap">${esc(f.hex)}</td><td class="mono wrap">${esc(f.text)}</td></tr>`).join('')}</tbody></table>`
: `<p class="muted">${esc(t('none_yet'))}</p>`}
${(r.guesses || []).length ? `<h3>${esc(t('guesses'))}</h3><table><tbody>${r.guesses.map(g => `<tr><td>${esc(g.name)}</td><td class="mono">${fmtValue(g.value)} ${esc(g.unit || '')}</td><td class="muted">×${esc(g.count)}</td></tr>`).join('')}</tbody></table>` : ''}`;
}
async function saveManual() {
const name = $('#m-name').value.trim();
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/.test(name)) { toast(t('err_name')); return; }
const framing = $('#m-framing').value; const protocol = $('#m-sproto').value;
const params = {};
if (protocol === 'custom_binary') Object.assign(params, {pattern: $('#c-pattern').value, value_offset: +$('#c-offset').value,
value_length: +$('#c-length').value, byte_order: $('#c-order').value, signed: $('#c-signed').checked});
if (protocol === 'custom_ascii') params.regex = $('#c-regex').value;
const proto = S.protocols.find(p => p.id === protocol) || {};
const request = $('#m-request').value.trim();
const body = {name, label: $('#m-label').value.trim(), port: $('#m-port').value, protocol, params,
serial: {baudrate: +$('#m-baud').value, bytesize: +framing[0], parity: framing[1], stopbits: +framing[2]},
exchange: {request_hex: request, after_ack_hex: proto.after_ack_hex || '', poll_interval_ms: +$('#m-poll').value || 500},
unit: $('#m-unit').value, output_unit: $('#m-ounit').value, stable_samples: +$('#m-stable').value || 3};
stopMonitor();
const r = await api('/api/profiles', {body});
if (!r.ok) { toast(t('failed', {error: r.error})); return; }
S.state.profiles = r.profiles; toast(t('saved')); renderAll();
}
// ---------------------------------------------------------------- protocols & about
function renderProtocols() {
const el = $('#p-protocols');
el.innerHTML = `<div class="card"><h2>${esc(t('proto_title'))}</h2><p class="muted">${esc(t('proto_intro'))}</p>
<div style="overflow-x:auto"><table><thead><tr><th>${esc(t('protocol'))}</th><th>${esc(t('kind'))}</th><th>${esc(t('request_col'))}</th><th>${esc(t('families'))}</th><th>${esc(t('reference'))}</th></tr></thead><tbody>
${S.protocols.map(p => `<tr><td><b><bdi>${esc(p.name)}</bdi></b><br><span class="mono small muted">${esc(p.id)}</span>${p.field_tested ? `<br><span class="tag">${esc(t('field_tested'))}</span>` : ''}</td>
<td>${esc(t('kind_' + p.kind))}</td><td class="mono small">${esc(p.request_hex || '—')}${p.after_ack_hex ? ' → ' + esc(p.after_ack_hex) : ''}</td>
<td class="small">${esc(p.families)}</td><td class="small muted">${esc(p.reference)}</td></tr>`).join('')}
</tbody></table></div></div>`;
}
function renderAbout() {
const el = $('#p-about'); const a = ABOUT;
el.innerHTML = `<div class="card"><h2>${esc(t('about_title'))}</h2><p>${esc(t('about_text'))}</p><p>${esc(t('about_platform'))}</p>
<div class="kv" style="margin-top:12px">
<b>${esc(t('version'))}</b><span class="mono">${esc(a.version)}</span>
<b>${esc(t('author'))}</b><span>${esc(a.author)}</span>
<b>${esc(t('company'))}</b><span>${esc(a.company)}</span>
<b>${esc(t('website'))}</b><a class="ltr" href="${esc(a.website)}" target="_blank" rel="noopener">${esc(a.website)}</a>
<b>GitHub</b><a class="ltr" href="${esc(a.github)}" target="_blank" rel="noopener">${esc(a.github)}</a>
<b>LinkedIn</b><a class="ltr" href="${esc(a.linkedin)}" target="_blank" rel="noopener">${esc(a.linkedin)}</a>
<b>${esc(t('source'))}</b><a class="ltr" href="${esc(a.repository)}" target="_blank" rel="noopener">${esc(a.repository)}</a>
<b>${esc(t('license'))}</b><span>${esc(a.license)}</span>
</div>
<p class="small muted" style="margin-top:12px">${esc(t('license_text'))}</p>
<p class="small muted">${esc(a.copyright)}</p></div>`;
}
// ---------------------------------------------------------------- events & boot
document.addEventListener('click', async ev => {
const b = ev.target.closest('button'); if (!b || b.disabled) return;
if (b.dataset.tab) return setTab(b.dataset.tab);
if (b.dataset.go) return setTab(b.dataset.go);
if (b.id === 'langBtn') { S.lang = S.lang === 'fa' ? 'en' : 'fa'; try { localStorage.setItem('sb.lang', S.lang); } catch (e) {} return applyLang(); }
if (b.dataset.del) {
if (!confirm(t('confirm_delete', {name: b.dataset.del}))) return;
const r = await api('/api/profiles/' + encodeURIComponent(b.dataset.del), {method: 'DELETE'});
if (r.ok) { S.state.profiles = r.profiles; delete S.weights[b.dataset.del]; renderAll(); } else toast(t('failed', {error: r.error}));
return;
}
if (b.dataset.saveResult) return saveResult(b.dataset.saveResult);
if (b.dataset.copy) return copy(b.dataset.copy);
switch (b.id) {
case 'startDetect': return startDetect();
case 'cancelDetect': await api('/api/detect/cancel', {body: {}}); return;
case 'refreshPorts': return loadPorts();
case 'keyShow': S.showKey = !S.showKey; return renderConnect();
case 'keyCopy': return copy(S.state.token);
case 'keyNew':
if (!confirm(t('confirm_key'))) return;
{ const r = await api('/api/token', {body: {}}); if (r.ok) { S.state.token = r.token; S.showKey = true; renderConnect(); } }
return;
case 'fwOpen': return adminAction('/api/firewall', {remote_ip: $('#fwRemote').value.trim()});
case 'fwRemove': return adminAction('/api/firewall', {remove: true});
case 'asOn': return adminAction('/api/autostart', {enable: true});
case 'asOff': return adminAction('/api/autostart', {enable: false});
case 'allowedSave': {
const clients = $('#allowed').value.split(/[,\s]+/).filter(Boolean);
const r = await api('/api/allowed', {body: {clients}});
if (r.ok) { S.state.allowed_clients = r.allowed_clients; toast(t('saved')); } else toast(t('failed', {error: r.error}));
return;
}
case 'monBtn': return S.monitor ? stopMonitor() : startMonitor();
case 'm-save': return saveManual();
}
});
document.addEventListener('input', ev => {
const el = ev.target; const port = el.dataset && (el.dataset.name || el.dataset.label || el.dataset.unit);
if (!port) return;
const d = S.draft[port] = S.draft[port] || {};
if (el.dataset.name) d.name = el.value; else if (el.dataset.label) d.label = el.value; else d.unit = el.value;
});
document.addEventListener('change', ev => {
if (ev.target.name === 'port') {
S.sel = new Set([...document.querySelectorAll('input[name=port]:checked')].map(i => i.value));
}
if (ev.target.id === 'm-proto') {
const p = S.protocols.find(x => x.id === ev.target.value);
if (p) { $('#m-request').value = p.request_hex; const s = $('#m-sproto'); if (s) { s.value = p.id; renderCustom(); } }
}
if (ev.target.id === 'm-sproto') renderCustom();
});
async function refreshState(fresh) {
const r = await api('/api/state' + (fresh ? '?fresh=1' : ''));
if (r.ok) {
S.state = r; $('#simPill').classList.toggle('hidden', !r.simulate);
const typing = document.activeElement && document.activeElement.closest && document.activeElement.closest('#p-find');
renderAll(!!typing);
}
}
async function boot() {
const [state, protos, ports, job] = await Promise.all([api('/api/state'), api('/api/protocols'), api('/api/ports'), api('/api/detect')]);
if (protos.ok) S.protocols = protos.protocols;
if (ports.ok) S.ports = ports.ports;
if (state.ok) { S.state = state; $('#simPill').classList.toggle('hidden', !state.simulate); }
if (job.ok && job.job) { S.job = job.job; S.log = job.job.log; S.jobSince = job.job.log_size; if (job.job.running) pollDetect(); }
applyLang();
const tab = location.hash.replace('#', '');
setTab(['scales','find','connect','manual','protocols','about'].includes(tab) ? tab : ((state.profiles || []).length ? 'scales' : 'find'));
pollWeights();
}
boot();
"""
_REMOTE = r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>SyncPulse Scale Bridge</title>
<link rel="icon" href="__ICON__"><style>body{font:15px/1.6 "Segoe UI",Tahoma,sans-serif;max-width:640px;margin:40px auto;
padding:0 16px;color:#16202a;background:#f4f6f8}.card{background:#fff;border:1px solid #dde3e9;border-radius:12px;padding:20px}
a{color:#0f766e}small{color:#5b6b7a}</style></head><body><div class="card">
<h2>SyncPulse Scale Bridge __VERSION__</h2>
<p>This bridge serves the scales connected to this PC to the network (JSON over HTTP, see
<a href="__REPO__">the documentation</a>). Its control page opens only on the PC itself:
<b>http://127.0.0.1:__PORT__/</b></p>
<p dir="rtl">این پل وزن باسکول‌های وصل به این رایانه را روی شبکه در اختیار می‌گذارد. صفحه‌ی مدیریت فقط روی خود همین رایانه باز می‌شود.</p>
<p><small>__AUTHOR__ · <a href="__WEBSITE__">__WEBSITE__</a> · <a href="__GITHUB__">GitHub</a> ·
<a href="__LINKEDIN__">LinkedIn</a> · __LICENSE__</small></p></div></body></html>"""
def page() -> str:
head = ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
'<meta name="author" content="' + ABOUT["author"] + ' — ' + ABOUT["company"] + '">'
'<title>SyncPulse Scale Bridge</title><link rel="icon" href="' + ICON + '">'
'<style>' + _STYLE + '</style></head><body>')
about = json.dumps(ABOUT, ensure_ascii=False).replace("</", "<\\/")
return (head + _BODY.replace("__ICON__", ICON)
+ "<script>" + _SCRIPT.replace("__ABOUT__", about) + "</script></body></html>")
def remote_page(port: int = 8765) -> str:
values = {"__ICON__": ICON, "__VERSION__": ABOUT["version"], "__REPO__": ABOUT["repository"],
"__PORT__": str(port), "__AUTHOR__": f'{ABOUT["author"]} — {ABOUT["company"]}',
"__WEBSITE__": ABOUT["website"], "__GITHUB__": ABOUT["github"],
"__LINKEDIN__": ABOUT["linkedin"], "__LICENSE__": ABOUT["license"]}
html = _REMOTE
for k, v in values.items():
html = html.replace(k, v)
return html
+294
View File
@@ -0,0 +1,294 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
"""
Windows chores the bridge can do by itself, each behind one button:
* open the firewall for the bridge port (so other machines can reach it),
* start the bridge with Windows, before anyone logs in (a scheduled task that
runs as SYSTEM, never stops after 72 hours, and restarts if it fails).
Both need administrator rights; Windows shows its usual UAC prompt.
Everything returns plain dictionaries for the user interface.
"""
from __future__ import annotations
import ctypes
import logging
import os
import socket
import subprocess
import sys
import tempfile
import webbrowser
from typing import Optional
from . import PRODUCT
log = logging.getLogger("scale_bridge")
RULE_NAME = PRODUCT
TASK_NAME = PRODUCT
CREATE_NO_WINDOW = 0x08000000
ERROR_CANCELLED = 1223
def is_windows() -> bool:
return sys.platform == "win32"
def is_admin() -> bool:
if not is_windows():
return os.geteuid() == 0 if hasattr(os, "geteuid") else False
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception: # noqa: BLE001
return False
def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def program_command(extra: str) -> tuple[str, str, str]:
"""(executable, arguments, working directory) that start this program again."""
if is_frozen():
exe = os.path.abspath(sys.executable)
return exe, extra, os.path.dirname(exe)
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.abspath(sys.executable), f"-m scale_bridge {extra}", root
def _run(args: list[str], timeout: float = 20) -> subprocess.CompletedProcess:
return subprocess.run(args, capture_output=True, timeout=timeout,
creationflags=CREATE_NO_WINDOW if is_windows() else 0)
# ---------------------------------------------------------------------------
# Elevation
# ---------------------------------------------------------------------------
def run_elevated(exe: str, params: str, timeout: float = 120) -> dict:
"""Run a command as administrator (UAC prompt) and wait for it."""
if not is_windows():
return {"ok": False, "error": "Windows only"}
if is_admin():
try:
proc = subprocess.run(f'"{exe}" {params}', capture_output=True, timeout=timeout,
creationflags=CREATE_NO_WINDOW)
return {"ok": proc.returncode == 0, "code": proc.returncode}
except (OSError, subprocess.SubprocessError) as e:
return {"ok": False, "error": str(e)}
from ctypes import wintypes
class SHELLEXECUTEINFOW(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND),
("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR),
("lpParameters", wintypes.LPCWSTR), ("lpDirectory", wintypes.LPCWSTR),
("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE),
("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR),
("hkeyClass", wintypes.HKEY), ("dwHotKey", wintypes.DWORD),
("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)]
SEE_MASK_NOCLOSEPROCESS = 0x00000040
SEE_MASK_NO_CONSOLE = 0x00008000
info = SHELLEXECUTEINFOW()
info.cbSize = ctypes.sizeof(info)
info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE
info.lpVerb = "runas"
info.lpFile = exe
info.lpParameters = params
info.nShow = 0 # SW_HIDE
shell32 = ctypes.windll.shell32
kernel32 = ctypes.windll.kernel32
shell32.ShellExecuteExW.argtypes = [ctypes.POINTER(SHELLEXECUTEINFOW)]
if not shell32.ShellExecuteExW(ctypes.byref(info)):
err = ctypes.GetLastError()
if err == ERROR_CANCELLED:
return {"ok": False, "cancelled": True, "error": "cancelled at the Windows prompt"}
return {"ok": False, "error": f"Windows error {err}"}
try:
kernel32.WaitForSingleObject(info.hProcess, int(timeout * 1000))
code = wintypes.DWORD()
kernel32.GetExitCodeProcess(info.hProcess, ctypes.byref(code))
return {"ok": code.value == 0, "code": code.value}
finally:
kernel32.CloseHandle(info.hProcess)
# ---------------------------------------------------------------------------
# Firewall
# ---------------------------------------------------------------------------
def firewall_status(port: int) -> dict:
if not is_windows():
return {"supported": False}
try:
proc = _run(["netsh", "advfirewall", "firewall", "show", "rule", f"name={RULE_NAME}"])
except (OSError, subprocess.SubprocessError) as e:
return {"supported": True, "exists": False, "error": str(e)}
exists = proc.returncode == 0
return {"supported": True, "exists": exists,
"port_ok": exists and str(port).encode() in proc.stdout, "rule": RULE_NAME}
def firewall_open(port: int, remote_ip: Optional[str] = None) -> dict:
"""Allow inbound TCP `port` on every network profile (optionally from one address only).
When Windows asked "allow access?" the first time the program listened and
somebody clicked Cancel, Windows added BLOCK rules for the program, and a
block rule beats any allow rule — so those are removed as well.
"""
if not is_windows():
return {"ok": False, "error": "Windows only"}
port = int(port)
remote = f" remoteip={remote_ip}" if remote_ip else ""
steps = [f'netsh advfirewall firewall delete rule name="{RULE_NAME}" >nul 2>&1']
if is_frozen():
steps.append(f'netsh advfirewall firewall delete rule name=all program="{sys.executable}" >nul 2>&1')
steps.append(f'netsh advfirewall firewall add rule name="{RULE_NAME}" dir=in action=allow '
f'protocol=TCP localport={port} profile=any{remote} '
f'description="{PRODUCT}: lets other machines read the scales on this PC"')
result = run_elevated(os.environ.get("COMSPEC", "cmd.exe"), "/c " + " & ".join(steps))
result["status"] = firewall_status(port)
result["ok"] = bool(result["status"].get("port_ok"))
return result
def firewall_close() -> dict:
if not is_windows():
return {"ok": False, "error": "Windows only"}
return run_elevated("netsh", f'advfirewall firewall delete rule name="{RULE_NAME}"')
# ---------------------------------------------------------------------------
# Start with Windows
# ---------------------------------------------------------------------------
def _xml_escape(text: str) -> str:
return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;"))
def task_xml(exe: str, arguments: str, workdir: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>{_xml_escape(PRODUCT)}: serves the scales on this PC to the network.</Description>
</RegistrationInfo>
<Triggers>
<BootTrigger>
<Enabled>true</Enabled>
<Delay>PT20S</Delay>
</BootTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>S-1-5-18</UserId>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>6</Priority>
<RestartOnFailure>
<Interval>PT1M</Interval>
<Count>999</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{_xml_escape(exe)}</Command>
<Arguments>{_xml_escape(arguments)}</Arguments>
<WorkingDirectory>{_xml_escape(workdir)}</WorkingDirectory>
</Exec>
</Actions>
</Task>
"""
def autostart_status() -> dict:
if not is_windows():
return {"supported": False}
try:
proc = _run(["schtasks", "/Query", "/TN", TASK_NAME, "/XML"])
except (OSError, subprocess.SubprocessError) as e:
return {"supported": True, "enabled": False, "error": str(e)}
if proc.returncode != 0:
return {"supported": True, "enabled": False, "task": TASK_NAME}
xml = proc.stdout.decode("utf-16", errors="ignore") if proc.stdout[:2] in (b"\xff\xfe", b"\xfe\xff") \
else proc.stdout.decode("utf-8", errors="ignore")
exe, _, _ = program_command("")
return {"supported": True, "enabled": True, "task": TASK_NAME,
"this_program": exe.lower() in xml.lower()}
def autostart_enable(config_path: str) -> dict:
if not is_windows():
return {"ok": False, "error": "Windows only"}
exe, arguments, workdir = program_command(f'--service --config "{config_path}"')
fd, xml_path = tempfile.mkstemp(prefix="scale-bridge-task-", suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(task_xml(exe, arguments, workdir).encode("utf-16"))
try:
result = run_elevated("schtasks", f'/Create /TN "{TASK_NAME}" /XML "{xml_path}" /F')
finally:
try:
os.remove(xml_path)
except OSError:
pass
result["status"] = autostart_status()
result["ok"] = bool(result["status"].get("enabled"))
return result
def autostart_disable() -> dict:
if not is_windows():
return {"ok": False, "error": "Windows only"}
result = run_elevated("schtasks", f'/Delete /TN "{TASK_NAME}" /F')
result["status"] = autostart_status()
result["ok"] = not result["status"].get("enabled")
return result
# ---------------------------------------------------------------------------
# Network and desktop
# ---------------------------------------------------------------------------
def lan_addresses() -> list[str]:
found = set()
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("10.255.255.255", 1))
found.add(s.getsockname()[0])
except OSError:
pass
try:
for ip in socket.gethostbyname_ex(socket.gethostname())[2]:
if not ip.startswith("127."):
found.add(ip)
except OSError:
pass
return sorted(found)
def open_browser(url: str) -> None:
try:
webbrowser.open(url, new=1)
except Exception: # noqa: BLE001
log.info("open %s in a browser", url)