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:
+12
@@ -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('/')}>← 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} · 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user