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
+89 -16
View File
@@ -8,8 +8,10 @@ import logging
import sys
from typing import Optional
from config import (
COM_PORT, BAUD_RATE, TIMEOUT, WRITE_INTERVAL,
TEST_DATA_TYPE, SCALES_MIN, SCALES_MAX, SCALES_INCREMENT, DEBUG
COM_PORT, BAUD_RATE, TIMEOUT, WRITE_INTERVAL, TEST_DATA_TYPE,
EMPTY_MIN, EMPTY_MAX, FULL_MIN, FULL_MAX,
IDLE_SECONDS, RAMP_SECONDS, STABLE_SECONDS, DROP_SECONDS,
DIVISION_SMALL, DIVISION_LARGE, DIVISION_THRESHOLD, DEBUG
)
# Setup logging
@@ -29,7 +31,12 @@ class ComPortTestWriter:
self.is_connected = False
self.is_running = False
self.data_counter = 0
self.current_scales_value = SCALES_MIN
# Weighing cycle state
self.phase = 'idle'
self.phase_started = time.monotonic()
self.loaded = False # alternates: empty truck, then full truck
self.target = self._pick_target()
self.phase_start_value = 0.0
def connect(self) -> bool:
"""Connect to the serial port"""
@@ -72,20 +79,83 @@ class ComPortTestWriter:
self.is_connected = False
return False
@staticmethod
def _quantise(value: float) -> int:
"""Snap a weight to the scale's division.
Truck scales are multi-interval: they read in DIVISION_SMALL steps up to
DIVISION_THRESHOLD and DIVISION_LARGE steps above it. Nothing in between
is displayable.
"""
if value <= 0:
return 0
division = DIVISION_SMALL if value < DIVISION_THRESHOLD else DIVISION_LARGE
# floor(x + 0.5), not round() - Python rounds halves to even, so a reading
# exactly between two divisions would sometimes snap down.
return int((value + division / 2) // division) * division
def _pick_target(self) -> int:
"""Pick the weight for the next truck on the scale."""
if self.loaded:
return self._quantise(random.randint(FULL_MIN, FULL_MAX))
return self._quantise(random.randint(EMPTY_MIN, EMPTY_MAX))
def _advance_phase(self):
"""Move to the next phase of the weighing cycle."""
if self.phase == 'idle':
self.phase = 'ramp'
elif self.phase == 'ramp':
self.phase = 'stable'
elif self.phase == 'stable':
self.phase = 'drop'
else:
# Truck left the scale - next one is the other kind
self.phase = 'idle'
self.loaded = not self.loaded
self.target = self._pick_target()
self.phase_started = time.monotonic()
def generate_scales_data(self) -> str:
"""Generate simulated scales data"""
# Step through the range and reverse at each end
if not hasattr(self, '_direction'):
self._direction = 1
step = random.uniform(SCALES_INCREMENT * 0.8, SCALES_INCREMENT * 1.2)
self.current_scales_value += step * self._direction
if self.current_scales_value >= SCALES_MAX:
self._direction = -1
elif self.current_scales_value <= SCALES_MIN:
self._direction = 1
self.current_scales_value = max(SCALES_MIN, min(SCALES_MAX, self.current_scales_value))
return f"{self.current_scales_value:.2f} kg"
"""Simulate a truck being weighed: idle at 0, drive on, settle, drive off.
Emits "<weight> kg ST" while the reading is stable (balanced), "US" otherwise,
mirroring how real indicators report stability.
"""
durations = {
'idle': IDLE_SECONDS,
'ramp': RAMP_SECONDS,
'stable': STABLE_SECONDS,
'drop': DROP_SECONDS,
}
# Roll over any fully elapsed phases first, so the value below is always
# computed from the phase it actually belongs to.
elapsed = time.monotonic() - self.phase_started
while elapsed >= durations[self.phase]:
self._advance_phase()
elapsed = 0.0
duration = durations[self.phase]
progress = min(1.0, elapsed / duration) if duration > 0 else 1.0
if self.phase == 'idle':
value, balanced = 0.0, False
elif self.phase == 'ramp':
# Climbing under load - noisy and unstable
value = self.target * min(1.0, progress + 0.05) + random.uniform(-40, 40)
value = max(0.0, value)
balanced = False
elif self.phase == 'stable':
# Settled on the target; drift is small enough that it usually rests
# on one division and only occasionally flickers to the next.
value = self.target + random.uniform(-12, 12)
balanced = True
else: # drop
value = self.target * (1.0 - progress) + random.uniform(-40, 40)
value = max(0.0, value)
balanced = False
return f"{self._quantise(value)} kg {'ST' if balanced else 'US'}"
def generate_counter_data(self) -> str:
"""Generate incrementing counter data"""
self.data_counter += 1
@@ -125,6 +195,9 @@ class ComPortTestWriter:
logger.info(f"Baud Rate: {self.baudrate}")
logger.info(f"Data Type: {TEST_DATA_TYPE}")
logger.info(f"Write Interval: {WRITE_INTERVAL}s")
if TEST_DATA_TYPE == 'scales':
logger.info(f"Cycle: idle {IDLE_SECONDS}s -> ramp {RAMP_SECONDS}s -> stable {STABLE_SECONDS}s -> drop {DROP_SECONDS}s")
logger.info(f"Empty truck: {EMPTY_MIN}-{EMPTY_MAX} kg | Full truck: {FULL_MIN}-{FULL_MAX} kg")
logger.info(f"{'='*50}\n")
if not self.connect():