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
@@ -0,0 +1,365 @@
.scale-monitor {
min-height: 100vh;
background-color: #f5f5f7;
}
.sm-body {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.sm-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.sm-back {
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
cursor: pointer;
font-size: 13px;
}
.sm-back:hover {
border-color: #667eea;
color: #667eea;
}
.sm-conn {
font-size: 12px;
font-weight: 600;
padding: 3px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.sm-conn--on {
background-color: #e8f5e9;
color: #2e7d32;
}
.sm-conn--off {
background-color: #fee;
color: #c62828;
}
.sm-status {
font-size: 12px;
color: #888;
font-family: 'Courier New', monospace;
}
.sm-message {
padding: 8px 12px;
background-color: #eef0ff;
border: 1px solid #ccd;
border-radius: 6px;
color: #445;
font-size: 13px;
margin-bottom: 12px;
}
.sm-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 1000px) {
.sm-columns {
grid-template-columns: 1fr;
}
}
.sm-panel {
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.sm-panel--wide {
margin-top: 16px;
}
.sm-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sm-panel-head h3 {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sm-panel-actions {
display: flex;
gap: 6px;
}
.sm-panel-actions button {
padding: 4px 10px;
background: none;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
font-size: 12px;
cursor: pointer;
}
.sm-panel-actions button:hover {
border-color: #667eea;
color: #667eea;
}
.sm-send {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.sm-send input {
flex: 1;
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
font-family: 'Courier New', monospace;
}
.sm-send button {
padding: 6px 14px;
background: linear-gradient(135deg, #667eea, #764ba2);
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
cursor: pointer;
}
.sm-send button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sm-lines {
height: 420px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 6px;
background-color: #fafafa;
}
.sm-line {
padding: 5px 8px;
border-bottom: 1px solid #eee;
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 12px;
}
.sm-line:hover {
background-color: #f0f0ff;
}
.sm-line--selected {
background-color: #e8eaff;
}
.sm-line-text {
display: flex;
justify-content: space-between;
gap: 8px;
color: #333;
}
.sm-visible-space {
white-space: pre;
word-break: break-all;
}
.sm-len {
color: #aaa;
flex-shrink: 0;
}
.sm-line-hex {
color: #999;
font-size: 11px;
word-break: break-all;
margin-top: 2px;
}
.sm-empty {
padding: 20px;
text-align: center;
color: #999;
font-style: italic;
font-size: 13px;
}
.sm-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.sm-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.sm-form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sm-form input[type="text"],
.sm-form input[type="number"] {
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
text-transform: none;
letter-spacing: normal;
font-weight: 400;
color: #333;
}
.sm-check {
flex-direction: row !important;
align-items: center;
gap: 6px;
text-transform: none !important;
letter-spacing: normal !important;
font-weight: 400 !important;
color: #555 !important;
font-size: 12px !important;
}
.sm-hint {
font-size: 11px;
color: #999;
font-style: italic;
}
.sm-preview {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #eee;
}
.sm-preview h4 {
margin: 0 0 8px 0;
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sm-preview-ok {
display: flex;
align-items: baseline;
gap: 10px;
}
.sm-preview-weight {
font-size: 24px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: #333;
}
.sm-bal {
color: #2e7d32;
font-size: 12px;
font-weight: 600;
}
.sm-unbal {
color: #c62828;
font-size: 12px;
font-weight: 600;
}
.sm-preview-error {
color: #c62828;
font-size: 13px;
font-style: italic;
}
.sm-save {
margin-top: 12px;
padding: 8px 16px;
background: linear-gradient(135deg, #667eea, #764ba2);
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.sm-save:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.sm-protocols {
display: flex;
flex-direction: column;
gap: 6px;
}
.sm-protocol {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #eee;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.sm-protocol:hover {
background-color: #f0f0ff;
}
.sm-protocol-name {
font-weight: 600;
color: #333;
}
.sm-protocol-kind,
.sm-protocol-poll {
font-size: 11px;
color: #999;
font-family: 'Courier New', monospace;
}
.sm-protocol-poll {
margin-left: auto;
}
@@ -0,0 +1,299 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../Header';
import useSerialData from '../../hooks/useSerialData';
import { useScaleProtocols } from '../../contexts/ScaleProtocolContext';
import { parseReading, formatHex } from '../../services/scaleProtocols';
import api from '../../services/api';
import './ScaleMonitor.css';
const SERIAL_URL = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
const EMPTY_BUILDER = {
name: '',
poll_command: '',
poll_interval: 0.2,
weight_start: 1, // shown 1-based, stored 0-based
weight_length: 8,
stable_pos: 22, // shown 1-based, stored 0-based
stable_char: ' ',
stable_means_balanced: true,
decimals: 0,
};
export default function ScaleMonitor() {
const navigate = useNavigate();
const { readings, isConnected } = useSerialData();
const { protocols, selectedProtocol, selectProtocol, refresh } = useScaleProtocols();
const [paused, setPaused] = useState(false);
const [captured, setCaptured] = useState([]);
const [command, setCommand] = useState('');
const [status, setStatus] = useState(null);
const [message, setMessage] = useState('');
const [builder, setBuilder] = useState(EMPTY_BUILDER);
const [testLine, setTestLine] = useState(null);
const seen = useRef(new Set());
// Accumulate distinct readings so the builder can be tested against real data
useEffect(() => {
if (paused || !readings.length) return;
const latest = readings[0];
const key = latest.hex || latest.data;
if (seen.current.has(key)) return;
seen.current.add(key);
setCaptured(prev => [latest, ...prev].slice(0, 200));
}, [readings, paused]);
useEffect(() => {
fetch(`${SERIAL_URL}/status`)
.then(r => r.json())
.then(setStatus)
.catch(() => setStatus(null));
}, []);
const handleSend = async () => {
if (!command) return;
setMessage('');
try {
const res = await fetch(`${SERIAL_URL}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command }),
});
const body = await res.json();
setMessage(res.ok ? `Sent ${JSON.stringify(command)}` : (body.error || 'Send failed'));
} catch {
setMessage('Bridge not reachable');
}
};
const handleClear = () => {
seen.current = new Set();
setCaptured([]);
setTestLine(null);
};
// Convert the 1-based form values to the 0-based offsets the parser uses
const builderProtocol = useMemo(() => ({
kind: 'fixed_column',
config: {
weight_start: Math.max(0, builder.weight_start - 1),
weight_length: builder.weight_length,
stable_pos: Math.max(0, builder.stable_pos - 1),
stable_char: builder.stable_char,
stable_means_balanced: builder.stable_means_balanced,
decimals: builder.decimals,
},
}), [builder]);
const preview = useMemo(() => {
const line = testLine || captured[0];
if (!line) return null;
return { line, result: parseReading(line, builderProtocol) };
}, [testLine, captured, builderProtocol]);
const handleSaveProtocol = async () => {
if (!builder.name.trim()) {
setMessage('Give the protocol a name');
return;
}
setMessage('');
try {
await api.post('/api/scale-protocols/', {
name: builder.name.trim(),
kind: 'fixed_column',
poll_command: builder.poll_command,
poll_interval: builder.poll_interval,
config: builderProtocol.config,
});
await refresh();
setMessage(`Saved "${builder.name.trim()}"`);
} catch (err) {
const data = err.response?.data;
setMessage(typeof data === 'string' ? data : JSON.stringify(data) || 'Save failed');
}
};
const set = (key, value) => setBuilder(prev => ({ ...prev, [key]: value }));
return (
<div className="scale-monitor">
<Header />
<div className="sm-body">
<div className="sm-toolbar">
<button className="sm-back" onClick={() => navigate('/')}>&larr; Back</button>
<span className={`sm-conn ${isConnected ? 'sm-conn--on' : 'sm-conn--off'}`}>
{isConnected ? 'Bridge connected' : 'Bridge offline'}
</span>
{status && (
<span className="sm-status">
{status.com_port} @ {status.baud_rate} &middot; v{status.version}
{status.poll?.command
? ` · polling ${JSON.stringify(status.poll.command)} every ${status.poll.interval}s`
: ' · continuous'}
</span>
)}
</div>
{message && <div className="sm-message">{message}</div>}
<div className="sm-columns">
{/* Raw stream */}
<div className="sm-panel">
<div className="sm-panel-head">
<h3>Raw stream</h3>
<div className="sm-panel-actions">
<button onClick={() => setPaused(p => !p)}>
{paused ? 'Resume' : 'Pause'}
</button>
<button onClick={handleClear}>Clear</button>
</div>
</div>
<div className="sm-send">
<input
type="text"
value={command}
onChange={e => setCommand(e.target.value)}
placeholder="Probe command, e.g. %p"
/>
<button onClick={handleSend} disabled={!command}>Send</button>
</div>
<div className="sm-lines">
{captured.length === 0 ? (
<div className="sm-empty">
{isConnected ? 'Waiting for data...' : 'Bridge not connected'}
</div>
) : captured.map((r, i) => (
<div
key={i}
className={`sm-line ${testLine === r ? 'sm-line--selected' : ''}`}
onClick={() => setTestLine(r)}
title="Click to test the builder against this line"
>
<div className="sm-line-text">
{/* Show trailing spaces - in some protocols a space IS the
stability flag, so it must not look like nothing. */}
<span className="sm-visible-space">{(r.data || '').replace(/ /g, '·')}</span>
<span className="sm-len">{(r.data || '').length}</span>
</div>
<div className="sm-line-hex">{formatHex(r.hex)}</div>
</div>
))}
</div>
</div>
{/* Builder */}
<div className="sm-panel">
<div className="sm-panel-head">
<h3>Fixed-column protocol</h3>
</div>
<div className="sm-form">
<label>Name
<input type="text" value={builder.name}
onChange={e => set('name', e.target.value)}
placeholder="e.g. Elikom 22-char" />
</label>
<div className="sm-row">
<label>Poll command
<input type="text" value={builder.poll_command}
onChange={e => set('poll_command', e.target.value)}
placeholder="blank = continuous" />
</label>
<label>Interval (s)
<input type="number" step="0.1" min="0.1" value={builder.poll_interval}
onChange={e => set('poll_interval', parseFloat(e.target.value) || 0.2)} />
</label>
</div>
<div className="sm-row">
<label>Weight starts at char
<input type="number" min="1" value={builder.weight_start}
onChange={e => set('weight_start', parseInt(e.target.value, 10) || 1)} />
</label>
<label>Length
<input type="number" min="1" value={builder.weight_length}
onChange={e => set('weight_length', parseInt(e.target.value, 10) || 1)} />
</label>
</div>
<div className="sm-row">
<label>Stability at char
<input type="number" min="1" value={builder.stable_pos}
onChange={e => set('stable_pos', parseInt(e.target.value, 10) || 1)} />
</label>
<label>Balanced char
<input type="text" maxLength={1} value={builder.stable_char}
onChange={e => set('stable_char', e.target.value || ' ')}
placeholder="space" />
</label>
</div>
<div className="sm-row">
<label>Decimals
<input type="number" min="0" max="5" value={builder.decimals}
onChange={e => set('decimals', parseInt(e.target.value, 10) || 0)} />
</label>
<label className="sm-check">
<input type="checkbox" checked={builder.stable_means_balanced}
onChange={e => set('stable_means_balanced', e.target.checked)} />
That char means balanced
</label>
</div>
<div className="sm-hint">
Character positions are 1-based, counting from the start of the line.
</div>
</div>
<div className="sm-preview">
<h4>Preview</h4>
{!preview ? (
<div className="sm-empty">No data captured yet</div>
) : preview.result.error ? (
<div className="sm-preview-error">{preview.result.error}</div>
) : (
<div className="sm-preview-ok">
<span className="sm-preview-weight">{preview.result.weight} kg</span>
<span className={preview.result.balanced ? 'sm-bal' : 'sm-unbal'}>
{preview.result.balanced ? 'balanced' : 'not balanced'}
</span>
</div>
)}
<button className="sm-save" onClick={handleSaveProtocol}>Save protocol</button>
</div>
</div>
</div>
{/* Active protocol */}
<div className="sm-panel sm-panel--wide">
<div className="sm-panel-head">
<h3>Protocol used by this station</h3>
</div>
<div className="sm-protocols">
{protocols.map(p => (
<label key={p.id} className="sm-protocol">
<input
type="radio"
name="active-protocol"
checked={selectedProtocol?.id === p.id}
onChange={() => selectProtocol(p.id)}
/>
<span className="sm-protocol-name">{p.name}</span>
<span className="sm-protocol-kind">{p.kind}</span>
{p.poll_command
? <span className="sm-protocol-poll">polls {JSON.stringify(p.poll_command)}</span>
: <span className="sm-protocol-poll">continuous</span>}
</label>
))}
</div>
</div>
</div>
</div>
);
}