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:
+176
-15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user