bff6d70b17
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
419 lines
18 KiB
Python
419 lines
18 KiB
Python
# 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)
|