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
+12
View File
@@ -61,3 +61,15 @@ yarn-error.log*
# Claude Code temporary files
tmpclaude-*
nul
# Serial bridge runtime state (per-machine, not source)
serial_bridge/poll_config.json
serial_bridge/serial_bridge.log
# Licence signing - the private key must NEVER be committed.
# If it leaks, every licence ever issued becomes forgeable.
tools/license_private.key
*license*.key
# Local config backups
*.env.bak
@@ -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>
);
}
@@ -0,0 +1,100 @@
import { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useNomenclatures } from './NomenclatureContext';
import api from '../services/api';
const ScaleProtocolContext = createContext(null);
// Which protocol this station uses. Deployment is flexible (one central Django
// at the port, single-machine elsewhere), so the station is identified by the
// bridge it talks to on localhost - this only records the operator's choice.
const SELECTED_KEY = 'scalesapp.selectedProtocolId';
export const useScaleProtocols = () => {
const ctx = useContext(ScaleProtocolContext);
if (!ctx) throw new Error('useScaleProtocols must be used within ScaleProtocolProvider');
return ctx;
};
export function ScaleProtocolProvider({ children }) {
const { subscribeToUpdates } = useNomenclatures();
const [protocols, setProtocols] = useState([]);
const [selectedId, setSelectedId] = useState(() => {
try {
const stored = localStorage.getItem(SELECTED_KEY);
return stored ? parseInt(stored, 10) : null;
} catch {
return null;
}
});
const [isLoaded, setIsLoaded] = useState(false);
const [error, setError] = useState(null);
// subscribeToUpdates is not memoized in NomenclatureContext - hold it in a
// ref so the SSE effect runs once instead of on every provider render.
const subscribeRef = useRef(subscribeToUpdates);
subscribeRef.current = subscribeToUpdates;
const load = useCallback(async () => {
try {
const res = await api.get('/api/scale-protocols/');
setProtocols(res.data.results || res.data);
setError(null);
} catch {
setError('Failed to load scale protocols');
} finally {
setIsLoaded(true);
}
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
const unsubscribe = subscribeRef.current((message) => {
if (message.object !== 'scale_protocol') return;
const { operation, data } = message;
setProtocols(prev => {
if (operation === 'delete') return prev.filter(p => p.id !== data.id);
const without = prev.filter(p => p.id !== data.id);
return [...without, data].sort((a, b) => a.name.localeCompare(b.name));
});
});
return unsubscribe;
}, []);
const selectProtocol = useCallback((id) => {
setSelectedId(id);
try {
if (id === null) localStorage.removeItem(SELECTED_KEY);
else localStorage.setItem(SELECTED_KEY, String(id));
} catch {
// Storage unavailable (private window, blocked site data) - the
// selection still applies for this session.
}
}, []);
// Fall back to the simulator so a fresh install shows a weight rather than
// an error before anyone has chosen a protocol.
const selectedProtocol = useMemo(() => {
if (!protocols.length) return null;
return protocols.find(p => p.id === selectedId)
|| protocols.find(p => p.kind === 'simulator')
|| protocols[0];
}, [protocols, selectedId]);
const value = {
protocols,
selectedProtocol,
selectedId,
selectProtocol,
isLoaded,
error,
refresh: load,
};
return (
<ScaleProtocolContext.Provider value={value}>
{children}
</ScaleProtocolContext.Provider>
);
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Scale protocol parsers.
*
* The serial bridge is pure transport - it streams raw lines and knows nothing
* about protocols. Parsing happens here, driven by definitions stored in Django,
* so supporting a new scale type never requires updating the bridge binary that
* runs on each station PC.
*
* Every parser returns the same shape:
* { weight: number|null, balanced: boolean, error: string|null }
*
* Toledo Continuous and SICS are implemented from documented structure and have
* NOT been validated against real hardware. They deliberately return an error
* rather than a best guess when a frame does not match - a silently wrong weight
* on a weighbridge is worse than no weight at all.
*/
const fail = (error) => ({ weight: null, balanced: false, error });
const ok = (weight, balanced) => ({ weight, balanced, error: null });
/** Decode the bridge's hex field into a byte array. */
export function hexToBytes(hex) {
if (!hex) return [];
const out = [];
for (let i = 0; i + 1 < hex.length; i += 2) {
out.push(parseInt(hex.slice(i, i + 2), 16));
}
return out;
}
/** Render bytes as space-separated hex, for the admin monitor. */
export function formatHex(hex) {
if (!hex) return '';
return (hex.match(/.{1,2}/g) || []).join(' ').toUpperCase();
}
/**
* Fixed-column: weight and stability live at known character offsets.
* Covers the %p scale and most no-name indicators.
*
* Offsets are 0-based. Note the raw line must NOT be trimmed before reading
* stable_pos - in the %p format a SPACE means balanced, so stripping trailing
* whitespace would invert the signal and make every reading look unstable.
*/
function parseFixedColumn(reading, config) {
const {
weight_start: start = 0,
weight_length: length = 0,
stable_pos: stablePos = null,
stable_char: stableChar = ' ',
stable_means_balanced: stableMeans = true,
decimals = 0,
} = config || {};
const line = reading.data ?? '';
if (length < 1) return fail('weight_length must be at least 1');
if (line.length < start + length) {
return fail(`Line too short: need ${start + length} chars, got ${line.length}`);
}
const raw = line.substr(start, length).trim();
if (!raw) return fail('Weight field is empty');
const value = parseFloat(raw);
if (isNaN(value)) return fail(`Weight field is not a number: "${raw}"`);
let balanced = false;
if (stablePos !== null && stablePos >= 0) {
if (line.length <= stablePos) {
return fail(`Line too short for stability at position ${stablePos}`);
}
const matches = line[stablePos] === stableChar;
balanced = stableMeans ? matches : !matches;
}
return ok(decimals > 0 ? value / Math.pow(10, decimals) : value, balanced);
}
/**
* Simulator format used by the test writer: "16480 kg ST" / "... US".
* The word boundary matters so a value ending in e.g. "BEST" is not read as stable.
*/
function parseSimulator(reading) {
const line = reading.data ?? '';
const match = line.match(/-?[\d.]+/);
if (!match) return fail('No number found in reading');
const value = parseFloat(match[0]);
if (isNaN(value)) return fail(`Not a number: "${match[0]}"`);
return ok(Math.round(value), /\bST\s*$/.test(line));
}
/**
* Toledo Continuous Output.
*
* STX | SWA SWB SWC | 6 weight digits | 6 tare digits | CR | [checksum]
*
* SWA carries the decimal-point position; SWB bit 3 is the motion flag, so a
* stable reading is the ABSENCE of that bit. Weight digits are ASCII, space
* padded. Parsed from the hex field because the frame is binary.
*
* UNVALIDATED against real hardware.
*/
function parseToledoContinuous(reading) {
const bytes = hexToBytes(reading.hex);
if (!bytes.length) return fail('No raw bytes available for binary protocol');
const stx = bytes.indexOf(0x02);
if (stx === -1) return fail('No STX found in frame');
if (bytes.length < stx + 16) return fail('Frame too short for Toledo Continuous');
const swa = bytes[stx + 1];
const swb = bytes[stx + 2];
// Status words are 0x20-ORed ASCII; anything else means we are misaligned.
if ((swa & 0x30) !== 0x20 || (swb & 0x30) !== 0x20) {
return fail('Status words malformed - frame may not be Toledo Continuous');
}
const digits = bytes.slice(stx + 4, stx + 10)
.map(b => String.fromCharCode(b)).join('').trim();
if (!/^\d+$/.test(digits)) return fail(`Weight digits malformed: "${digits}"`);
let value = parseInt(digits, 10);
// SWA bits 0-2 encode the decimal point position.
const decimalPoint = swa & 0x07;
const decimals = [0, 0, 1, 2, 3, 4, 5, 0][decimalPoint] ?? 0;
if (decimals > 0) value = value / Math.pow(10, decimals);
if (swb & 0x01) value = -value; // bit 0: sign
const balanced = !(swb & 0x08); // bit 3: motion (set = moving)
return ok(value, balanced);
}
/**
* Mettler-Toledo SICS response, e.g.
* "S S 16480 kg" stable
* "S D 16480 kg" dynamic (in motion)
* "S I" / "S +" / "S -" error states
*
* UNVALIDATED against real hardware.
*/
function parseSics(reading) {
const line = (reading.data ?? '').trim();
if (!line) return fail('Empty response');
const parts = line.split(/\s+/);
if (parts.length < 2) return fail(`Unrecognised SICS response: "${line}"`);
const status = parts[1];
if (status === 'I') return fail('Scale busy (SICS "I")');
if (status === '+') return fail('Scale overload (SICS "+")');
if (status === '-') return fail('Scale underload (SICS "-")');
if (status !== 'S' && status !== 'D') {
return fail(`Unknown SICS status "${status}"`);
}
const value = parseFloat(parts[2]);
if (isNaN(value)) return fail(`SICS weight is not a number: "${parts[2]}"`);
return ok(value, status === 'S');
}
/**
* Parse one reading using the given protocol definition.
* `reading` is the bridge payload: { data, hex, ... }
*/
export function parseReading(reading, protocol) {
if (!reading) return fail('No reading');
if (!protocol) return fail('No protocol selected');
switch (protocol.kind) {
case 'fixed_column':
return parseFixedColumn(reading, protocol.config);
case 'simulator':
return parseSimulator(reading);
case 'toledo_continuous':
return parseToledoContinuous(reading);
case 'sics':
return parseSics(reading);
default:
return fail(`Unknown protocol kind "${protocol.kind}"`);
}
}
+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
+16 -5
View File
@@ -12,13 +12,24 @@ BAUD_RATE = int(os.getenv('BAUD_RATE', 9600))
TIMEOUT = int(os.getenv('TIMEOUT', 1))
# Test Data Settings
WRITE_INTERVAL = float(os.getenv('WRITE_INTERVAL', 1.0)) # seconds between writes
WRITE_INTERVAL = float(os.getenv('WRITE_INTERVAL', 0.2)) # seconds between writes
TEST_DATA_TYPE = os.getenv('TEST_DATA_TYPE', 'scales') # 'scales', 'counter', 'random'
# Scales data
SCALES_MIN = int(os.getenv('SCALES_MIN', 5000))
SCALES_MAX = int(os.getenv('SCALES_MAX', 20000))
SCALES_INCREMENT = float(os.getenv('SCALES_INCREMENT', 5.0))
# Scales data - simulated weighing cycle: idle at 0, ramp up, hold stable, drop back
EMPTY_MIN = int(os.getenv('EMPTY_MIN', 15000))
EMPTY_MAX = int(os.getenv('EMPTY_MAX', 22000))
FULL_MIN = int(os.getenv('FULL_MIN', 35000))
FULL_MAX = int(os.getenv('FULL_MAX', 45000))
IDLE_SECONDS = float(os.getenv('IDLE_SECONDS', 2.0)) # at 0 between trucks
RAMP_SECONDS = float(os.getenv('RAMP_SECONDS', 1.5)) # 0 -> target
STABLE_SECONDS = float(os.getenv('STABLE_SECONDS', 5.0)) # balanced at target
DROP_SECONDS = float(os.getenv('DROP_SECONDS', 1.0)) # target -> 0
# Scale division (multi-interval): readings snap to DIVISION_SMALL below
# DIVISION_THRESHOLD, DIVISION_LARGE at or above it.
DIVISION_SMALL = int(os.getenv('DIVISION_SMALL', 20))
DIVISION_LARGE = int(os.getenv('DIVISION_LARGE', 50))
DIVISION_THRESHOLD = int(os.getenv('DIVISION_THRESHOLD', 40000))
# Application Settings
DEBUG = os.getenv('DEBUG', 'True') == 'True'
+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():