diff --git a/.gitignore b/.gitignore
index 8318b49..c2b1d85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/frontend/src/components/ScaleMonitor/ScaleMonitor.css b/frontend/src/components/ScaleMonitor/ScaleMonitor.css
new file mode 100644
index 0000000..0cfc15f
--- /dev/null
+++ b/frontend/src/components/ScaleMonitor/ScaleMonitor.css
@@ -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;
+}
diff --git a/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx b/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx
new file mode 100644
index 0000000..b1a6875
--- /dev/null
+++ b/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx
@@ -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 (
+
+
+
+
+ navigate('/')}>← Back
+
+ {isConnected ? 'Bridge connected' : 'Bridge offline'}
+
+ {status && (
+
+ {status.com_port} @ {status.baud_rate} · v{status.version}
+ {status.poll?.command
+ ? ` · polling ${JSON.stringify(status.poll.command)} every ${status.poll.interval}s`
+ : ' · continuous'}
+
+ )}
+
+
+ {message &&
{message}
}
+
+
+ {/* Raw stream */}
+
+
+
Raw stream
+
+ setPaused(p => !p)}>
+ {paused ? 'Resume' : 'Pause'}
+
+ Clear
+
+
+
+
+ setCommand(e.target.value)}
+ placeholder="Probe command, e.g. %p"
+ />
+ Send
+
+
+
+ {captured.length === 0 ? (
+
+ {isConnected ? 'Waiting for data...' : 'Bridge not connected'}
+
+ ) : captured.map((r, i) => (
+
setTestLine(r)}
+ title="Click to test the builder against this line"
+ >
+
+ {/* Show trailing spaces - in some protocols a space IS the
+ stability flag, so it must not look like nothing. */}
+ {(r.data || '').replace(/ /g, '·')}
+ {(r.data || '').length}
+
+
{formatHex(r.hex)}
+
+ ))}
+
+
+
+ {/* Builder */}
+
+
+
Fixed-column protocol
+
+
+
+
Name
+ set('name', e.target.value)}
+ placeholder="e.g. Elikom 22-char" />
+
+
+
+ Poll command
+ set('poll_command', e.target.value)}
+ placeholder="blank = continuous" />
+
+ Interval (s)
+ set('poll_interval', parseFloat(e.target.value) || 0.2)} />
+
+
+
+
+ Weight starts at char
+ set('weight_start', parseInt(e.target.value, 10) || 1)} />
+
+ Length
+ set('weight_length', parseInt(e.target.value, 10) || 1)} />
+
+
+
+
+ Stability at char
+ set('stable_pos', parseInt(e.target.value, 10) || 1)} />
+
+ Balanced char
+ set('stable_char', e.target.value || ' ')}
+ placeholder="space" />
+
+
+
+
+ Decimals
+ set('decimals', parseInt(e.target.value, 10) || 0)} />
+
+
+ set('stable_means_balanced', e.target.checked)} />
+ That char means balanced
+
+
+
+
+ Character positions are 1-based, counting from the start of the line.
+
+
+
+
+
Preview
+ {!preview ? (
+
No data captured yet
+ ) : preview.result.error ? (
+
{preview.result.error}
+ ) : (
+
+ {preview.result.weight} kg
+
+ {preview.result.balanced ? 'balanced' : 'not balanced'}
+
+
+ )}
+
Save protocol
+
+
+
+
+ {/* Active protocol */}
+
+
+
Protocol used by this station
+
+
+ {protocols.map(p => (
+
+ selectProtocol(p.id)}
+ />
+ {p.name}
+ {p.kind}
+ {p.poll_command
+ ? polls {JSON.stringify(p.poll_command)}
+ : continuous }
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/contexts/ScaleProtocolContext.jsx b/frontend/src/contexts/ScaleProtocolContext.jsx
new file mode 100644
index 0000000..a06362b
--- /dev/null
+++ b/frontend/src/contexts/ScaleProtocolContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/services/scaleProtocols.js b/frontend/src/services/scaleProtocols.js
new file mode 100644
index 0000000..f8fab16
--- /dev/null
+++ b/frontend/src/services/scaleProtocols.js
@@ -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}"`);
+ }
+}
diff --git a/serial_bridge/app.py b/serial_bridge/app.py
index b954b5e..6a64432 100644
--- a/serial_bridge/app.py
+++ b/serial_bridge/app.py
@@ -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
diff --git a/test_comport_writer/config.py b/test_comport_writer/config.py
index d9244d2..1731c64 100644
--- a/test_comport_writer/config.py
+++ b/test_comport_writer/config.py
@@ -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'
diff --git a/test_comport_writer/test_writer.py b/test_comport_writer/test_writer.py
index b6bd6ee..c0419be 100644
--- a/test_comport_writer/test_writer.py
+++ b/test_comport_writer/test_writer.py
@@ -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 " 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():