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>
);
}
@@ -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}"`);
}
}