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 ( +
+
+
+
+ + + {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

+
+ + +
+
+ +
+ setCommand(e.target.value)} + placeholder="Probe command, e.g. %p" + /> + +
+ +
+ {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

+
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ 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'} + +
+ )} + +
+
+
+ + {/* Active protocol */} +
+
+

Protocol used by this station

+
+
+ {protocols.map(p => ( + + ))} +
+
+
+
+ ); +} 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():