Add multi-protocol scale support and admin COM monitor

The bridge becomes pure transport: it opens the port, writes a poll
string it is given at runtime, and streams raw lines. Protocol
definitions live in Django and parsing happens in the browser, so a new
scale type never means a new binary on the station PCs. The poll loop
stays in the bridge because a browser cannot be the heartbeat - hidden
tabs get throttled and a closed tab would stop the scale.

Every payload now carries a hex field. Toledo Continuous sends STX and
bit-packed status, which UTF-8 decoding silently corrupts. Fixed-column
protocols need the opposite care: a space can mean balanced, so the
trailing whitespace must survive.

Parsers for the simulator, fixed-column, Toledo Continuous and SICS
return an explicit error rather than a guess on a frame that does not
match, since a silently wrong weight on a weighbridge is worse than no
weight. Toledo and SICS are written from documented structure and have
not been validated against real hardware.

The admin monitor shows the raw stream as text and hex, sends one-off
probe commands, and builds fixed-column protocols against captured
lines. Spaces render as middots because in some protocols a space is
the stability flag.

The test writer now simulates a weighing cycle - idle, ramp, balanced,
drop - alternating empty and loaded trucks, quantised to the 20kg and
50kg divisions real truck scales use.

Bridge logging rotates and per-reading lines drop to DEBUG; the old
config had grown two log files totalling 368MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:57:49 +03:00
parent 67c9531600
commit 5a8527e96e
8 changed files with 1245 additions and 36 deletions
+176 -15
View File
@@ -5,12 +5,13 @@ No backend storage - direct streaming only
"""
import os
import logging
from logging.handlers import RotatingFileHandler
import serial
import serial.tools.list_ports
from dotenv import load_dotenv
from threading import Thread
from threading import Thread, Lock
from datetime import datetime
from flask import Flask, Response
from flask import Flask, Response, request
from flask_cors import CORS, cross_origin
from queue import Queue
import json
@@ -20,11 +21,16 @@ import time
load_dotenv()
# Configure logging
# Log next to this file, not the working directory, so starting the bridge from
# different places does not scatter logs around. Rotate so it cannot grow without
# bound - individual readings are DEBUG (see [RX] below), events are INFO.
_LOG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'serial_bridge.log')
logging.basicConfig(
level=os.getenv('LOG_LEVEL', 'INFO'),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('serial_bridge.log'),
RotatingFileHandler(_LOG_PATH, maxBytes=2 * 1024 * 1024, backupCount=3, encoding='utf-8'),
logging.StreamHandler()
]
)
@@ -33,12 +39,51 @@ logger = logging.getLogger(__name__)
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
# Bridge version - reported via /status so stale stations are visible centrally
BRIDGE_VERSION = '1.1.0'
# Where the last poll setting is remembered, so a bridge restarted with no
# browser open resumes polling instead of sitting idle.
POLL_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'poll_config.json')
# Global state
data_queue = Queue()
connected_clients = []
is_running = False
reader_thread = None
# Poll settings pushed by the UI. An empty command means the scale streams on
# its own and nothing should be written to it. The bridge never interprets the
# command - it only writes the bytes.
poll_config = {'command': '', 'interval': 0.2}
poll_lock = Lock()
serial_write_lock = Lock()
active_reader = None
def load_poll_config():
"""Restore the last poll setting saved by save_poll_config()."""
global poll_config
try:
if os.path.exists(POLL_CONFIG_PATH):
with open(POLL_CONFIG_PATH, 'r', encoding='utf-8') as fh:
stored = json.load(fh)
poll_config = {
'command': str(stored.get('command', '')),
'interval': float(stored.get('interval', 0.2)),
}
logger.info(f"[POLL] Restored: {poll_config}")
except Exception as e:
logger.error(f"Could not read {POLL_CONFIG_PATH}: {e}")
def save_poll_config():
try:
with open(POLL_CONFIG_PATH, 'w', encoding='utf-8') as fh:
json.dump(poll_config, fh)
except Exception as e:
logger.error(f"Could not write {POLL_CONFIG_PATH}: {e}")
class SerialReader:
def __init__(self, port, baudrate, timeout):
@@ -64,24 +109,54 @@ class SerialReader:
return False
def read_data(self):
"""Return the most recent reading, discarding any older buffered ones.
The scale streams faster than this loop runs, so lines accumulate in the
OS buffer. Only the latest weight is meaningful - returning the oldest
would make the display fall further behind the scale over time.
"""
if not self.is_connected:
return None
try:
if self.ser.in_waiting:
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
if line:
return {
'port': self.port,
'data': line,
'timestamp': datetime.now().isoformat(),
'received_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
raw = None
while self.ser.in_waiting:
candidate = self.ser.readline()
if candidate.strip():
raw = candidate
if raw:
# 'data' is a lossy text view for convenience; 'hex' is the real
# bytes. Binary protocols (Toledo sends STX and bit-packed status)
# must be parsed from 'hex' - decoding them as UTF-8 corrupts them.
return {
'port': self.port,
'data': raw.decode('utf-8', errors='ignore').rstrip('\r\n'),
'hex': raw.hex(),
'timestamp': datetime.now().isoformat(),
'received_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
except Exception as e:
logger.error(f"Error reading from {self.port}: {e}")
return None
def write_command(self, command: str) -> bool:
"""Write a command verbatim - no terminator is appended.
The original Delphi used TComPort.WriteStr('%p'), which sends exactly
those bytes, so anything extra would not be recognised by the scale.
"""
if not self.is_connected or not self.ser:
return False
try:
with serial_write_lock:
self.ser.write(command.encode('utf-8'))
return True
except Exception as e:
logger.error(f"Error writing to {self.port}: {e}")
return False
def disconnect(self):
if self.ser:
self.ser.close()
@@ -93,6 +168,8 @@ def read_serial_data():
"""Continuously read from serial port and queue data"""
global is_running
global active_reader
reader = SerialReader(
port=os.getenv('COM_PORT', 'COM3'),
baudrate=int(os.getenv('BAUD_RATE', '9600')),
@@ -103,16 +180,35 @@ def read_serial_data():
logger.error("Failed to connect to COM port")
return
active_reader = reader
read_interval = float(os.getenv('READ_INTERVAL', '0.5'))
while is_running:
try:
with poll_lock:
command = poll_config['command']
interval = poll_config['interval']
if command:
# Polled scale: it stays silent until asked. Give it a moment to
# answer before reading, or the reply lands on the next pass.
reader.write_command(command)
time.sleep(min(0.1, interval))
data = reader.read_data()
if data:
# Keep only the newest reading - a queued older weight is stale
# by the time a client reads it.
while not data_queue.empty():
try:
data_queue.get_nowait()
except Exception:
break
data_queue.put(data)
logger.info(f"[RX] {data['port']}: {data['data']}")
# Every reading, 5x a second - only useful when probing a scale.
logger.debug(f"[RX] {data['port']}: {data['data']}")
time.sleep(read_interval)
time.sleep(interval if command else read_interval)
except KeyboardInterrupt:
logger.info("Serial reader interrupted")
@@ -169,14 +265,77 @@ def events():
)
@app.route('/poll', methods=['GET', 'POST'])
@cross_origin()
def poll():
"""Get or set the poll command the bridge sends on its own timer.
The UI pushes this from the station's protocol definition, so a scale with a
different command needs no change to this program. An empty command puts the
bridge in continuous mode, where it only listens.
"""
global poll_config
if request.method == 'GET':
with poll_lock:
return dict(poll_config)
payload = request.get_json(silent=True) or {}
command = payload.get('command', '')
interval = payload.get('interval', 0.2)
if not isinstance(command, str):
return {'error': 'command must be a string'}, 400
try:
interval = float(interval)
except (TypeError, ValueError):
return {'error': 'interval must be a number'}, 400
if interval <= 0:
return {'error': 'interval must be greater than zero'}, 400
with poll_lock:
poll_config = {'command': command, 'interval': interval}
save_poll_config()
logger.info(f"[POLL] Set to {poll_config}")
return dict(poll_config)
@app.route('/send', methods=['POST'])
@cross_origin()
def send():
"""Write one command to the port without changing the poll loop.
Used by the admin monitor to probe an unknown scale.
"""
payload = request.get_json(silent=True) or {}
command = payload.get('command', '')
if not isinstance(command, str) or not command:
return {'error': 'command is required'}, 400
if active_reader is None or not active_reader.is_connected:
return {'error': 'serial port not connected'}, 503
if not active_reader.write_command(command):
return {'error': 'write failed'}, 500
logger.info(f"[TX] Sent command: {command!r}")
return {'sent': command}
@app.route('/status')
def status():
"""Get current status"""
with poll_lock:
current_poll = dict(poll_config)
return {
'status': 'running' if is_running else 'stopped',
'version': BRIDGE_VERSION,
'connected_clients': len(connected_clients),
'com_port': os.getenv('COM_PORT', 'COM3'),
'baud_rate': int(os.getenv('BAUD_RATE', '9600'))
'baud_rate': int(os.getenv('BAUD_RATE', '9600')),
'serial_connected': bool(active_reader and active_reader.is_connected),
'poll': current_poll
}
@@ -205,6 +364,8 @@ def start_app():
logger.info(f"SSE Endpoint: http://127.0.0.1:5000/events")
logger.info("=" * 60)
load_poll_config()
is_running = True
# Start serial reader thread