mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
slop
This commit is contained in:
@@ -1,77 +0,0 @@
|
||||
# Wii Balance Board weigh station
|
||||
|
||||
This service supports an original Nintendo `RVL-WBC-01` as an automatic rover
|
||||
weigh station. The server installer builds the native bridge, grants only that
|
||||
bridge `CAP_NET_ADMIN`, and installs a udev rule limited to the calibrated
|
||||
Balance Board input device.
|
||||
|
||||
## Commissioning on the real server
|
||||
|
||||
1. Set `balanceBoard.enabled: true` in `server/config.yaml`.
|
||||
2. Run `sudo ./install_server.sh` from the `server` directory. The installer
|
||||
configures BlueZ's Wii-compatible kernel HID mode, loads `hid-wiimote` now
|
||||
and at boot, and restarts Bluetooth and the rover server automatically.
|
||||
3. Open the Activities tab. When it says **Waiting for red Sync**, press the red
|
||||
Sync button under the board's battery cover.
|
||||
4. Wait for the card to report a paired bond and then **Ready**. BlueZ retains
|
||||
the bond and the service also stores the selected board address in
|
||||
`server/data/balance-board.json`.
|
||||
5. For ordinary use, press only the front power button. The board should connect
|
||||
to the server without another Sync operation.
|
||||
|
||||
The bridge keeps Bluetooth Classic discovery active while no board is
|
||||
commissioned and stops scanning after a successful bond. Once commissioned, it
|
||||
actively retries the saved address while disconnected so a front-button wake is
|
||||
caught even when the adapter does not accept the board's incoming reconnect.
|
||||
|
||||
Wii-family HID compatibility requires `UserspaceHID=false` and
|
||||
`ClassicBondedOnly=false` in BlueZ's `input.conf`. The installer applies only
|
||||
those two keys with an INI-aware tool and preserves unrelated Bluetooth input
|
||||
settings. The latter relaxes BlueZ's global Classic HID bonding restriction;
|
||||
this is limited to servers where Balance Board support is explicitly enabled.
|
||||
|
||||
## Physical station
|
||||
|
||||
Recess the board into a platform or place independent approach and departure
|
||||
ramps beside it. A ramp that rests partly on the board and partly on the floor
|
||||
will transfer some rover load to the floor and make the reading incorrect. At
|
||||
the stable capture position, every wheel must be supported by the board itself.
|
||||
|
||||
## Verification
|
||||
|
||||
On the server, useful checks are:
|
||||
|
||||
```sh
|
||||
getcap src/services/balanceBoardService/native/balance_board_worker
|
||||
modinfo hid-wiimote
|
||||
journalctl -u multirover.service -f
|
||||
```
|
||||
|
||||
The capability check should report `cap_net_admin=ep`. When the board connects,
|
||||
an input device named `Nintendo Wii Remote Balance Board` should appear under
|
||||
`/dev/input` and the Activities card should begin receiving corner loads.
|
||||
|
||||
Test at least the following before treating the station as unattended:
|
||||
|
||||
- Ten front-button wake, measurement, drive-off, and idle-disconnect cycles.
|
||||
- A server restart while the board is asleep.
|
||||
- A server restart while the board is connected.
|
||||
- A `bluetooth.service` restart followed by another front-button wake.
|
||||
- Battery removal and replacement without removing the stored BlueZ bond.
|
||||
|
||||
Replacing the server Bluetooth adapter, erasing `/var/lib/bluetooth`, or using
|
||||
**Forget board** invalidates the board's remembered host and requires the red
|
||||
Sync commissioning step again.
|
||||
|
||||
## Development simulation
|
||||
|
||||
The native worker has a cycle simulator that never opens Bluetooth or input
|
||||
devices:
|
||||
|
||||
```sh
|
||||
BALANCE_BOARD_SIMULATE=cycle ./native/balance_board_worker
|
||||
```
|
||||
|
||||
For a full local server/UI exercise, use a development config containing both
|
||||
`balanceBoard.enabled: true` and `balanceBoard.simulate: true`. The public
|
||||
example omits `simulate` because it is not a production hardware setting.
|
||||
@@ -1,6 +1,6 @@
|
||||
// Balance Board Hardware Bridge
|
||||
// Purpose: Supervises the capability-limited native worker and converts its JSON-line protocol into service events.
|
||||
// Scope: Owns process lifecycle, restart recovery, worker commands, and protocol validation; measurement policy remains in index.js.
|
||||
// Scope: Owns process lifecycle, restart recovery, shutdown, and protocol validation; scale policy remains in index.js.
|
||||
const { spawn } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const path = require('path');
|
||||
@@ -106,16 +106,6 @@ function createBalanceBoardHardware({ logger, address = '', simulate = false } =
|
||||
});
|
||||
}
|
||||
|
||||
function send(command, payload = {}) {
|
||||
if (!worker || worker.killed || !worker.stdin?.writable) {
|
||||
throw new Error('balance board worker is not running');
|
||||
}
|
||||
// Commands are intentionally a tiny fixed vocabulary. The native worker
|
||||
// never accepts shell fragments or arbitrary Bluetooth addresses from the
|
||||
// browser, so an admin maintenance action cannot become command execution.
|
||||
worker.stdin.write(`${JSON.stringify({ command, ...payload })}\n`);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopped = true;
|
||||
if (restartTimer) {
|
||||
@@ -139,28 +129,10 @@ function createBalanceBoardHardware({ logger, address = '', simulate = false } =
|
||||
}, 1500).unref();
|
||||
}
|
||||
|
||||
function restart() {
|
||||
if (worker) {
|
||||
const child = worker;
|
||||
worker = null;
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
|
||||
}, 1500).unref();
|
||||
}
|
||||
if (restartTimer) clearTimeout(restartTimer);
|
||||
restartTimer = setTimeout(() => {
|
||||
restartTimer = null;
|
||||
start();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
send,
|
||||
setAddress(nextAddress) {
|
||||
// The factory can be created before first commissioning. Preserve the
|
||||
// newly paired address for later bridge restarts in the same Node process
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Balance Board Service
|
||||
// Purpose: Turns calibrated corner loads into an automatic rover weigh-station lifecycle.
|
||||
// Scope: Owns configuration, tare/stability policy, session state, Socket.IO delivery, persistence, and admin maintenance actions.
|
||||
// Purpose: Exposes one Wii Balance Board as a self-pairing Bluetooth scale.
|
||||
// Scope: Stores the paired address, applies a small automatic tare, and publishes simple status plus live weight.
|
||||
const fs = require('fs');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
@@ -8,48 +8,16 @@ const logger = require('../../globals/logger').child('balanceBoardService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { isAdmin } = require('../roleService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { createBalanceBoardHardware } = require('./hardware');
|
||||
|
||||
const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const rawConfig = config.balanceBoard || {};
|
||||
const enabled = isFeatureEnabled('balanceBoard');
|
||||
const rawConfig = loadConfig().balanceBoard || {};
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('balance-board.json');
|
||||
const FRAME_ROOM = 'balance-board-viewers';
|
||||
const TARE_SAMPLE_COUNT = 20;
|
||||
const MAX_TARE_WEIGHT_KG = 2;
|
||||
const DEFAULTS = {
|
||||
minimumWeightKg: 1,
|
||||
stableDurationMs: 1500,
|
||||
stableToleranceKg: 0.15,
|
||||
exitWeightKg: 0.35,
|
||||
disconnectWhenEmptyMs: 30000,
|
||||
};
|
||||
|
||||
function finiteNumber(value, fallback, minimum = -Infinity, maximum = Infinity) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.max(minimum, Math.min(maximum, number));
|
||||
}
|
||||
|
||||
const settings = {
|
||||
minimumWeightKg: finiteNumber(rawConfig.minimumWeightKg, DEFAULTS.minimumWeightKg, 0.1, 100),
|
||||
stableDurationMs: finiteNumber(rawConfig.stableDurationMs, DEFAULTS.stableDurationMs, 250, 10000),
|
||||
stableToleranceKg: finiteNumber(rawConfig.stableToleranceKg, DEFAULTS.stableToleranceKg, 0.01, 5),
|
||||
exitWeightKg: finiteNumber(rawConfig.exitWeightKg, DEFAULTS.exitWeightKg, 0, 20),
|
||||
disconnectWhenEmptyMs: finiteNumber(
|
||||
rawConfig.disconnectWhenEmptyMs,
|
||||
DEFAULTS.disconnectWhenEmptyMs,
|
||||
0,
|
||||
30 * 60 * 1000,
|
||||
),
|
||||
// Simulation is intentionally undocumented in the public example config.
|
||||
// It exists for development and CI where no Bluetooth board is attached.
|
||||
simulate: Boolean(rawConfig.simulate || process.env.BALANCE_BOARD_SIMULATE),
|
||||
};
|
||||
const MAX_AUTOMATIC_TARE_KG = 2;
|
||||
|
||||
function loadStore() {
|
||||
try {
|
||||
@@ -57,280 +25,103 @@ function loadStore() {
|
||||
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
|
||||
return { address };
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') logger.warn('Failed to load Balance Board store', err.message);
|
||||
if (err.code !== 'ENOENT') logger.warn('Failed to load Balance Board address', err.message);
|
||||
return { address: '' };
|
||||
}
|
||||
}
|
||||
|
||||
let store = enabled ? loadStore() : { address: '' };
|
||||
let hardware = null;
|
||||
let phase = enabled ? (store.address ? 'waiting' : 'commissioning') : 'disabled';
|
||||
let hardwareState = enabled ? 'starting' : 'disabled';
|
||||
let connected = false;
|
||||
let lastError = null;
|
||||
let batteryPercent = null;
|
||||
let latestFrame = null;
|
||||
let lastMeasurement = null;
|
||||
let bluetooth = {
|
||||
available: null,
|
||||
paired: null,
|
||||
trusted: null,
|
||||
connected: null,
|
||||
wakeAllowed: null,
|
||||
};
|
||||
let inputState = enabled ? 'not-detected' : 'disabled';
|
||||
let bluetoothError = null;
|
||||
let inputError = null;
|
||||
let reconnectDetail = null;
|
||||
let diagnosticsUpdatedAt = null;
|
||||
let lastConnectedAt = null;
|
||||
let tareSamples = [];
|
||||
let tare = { topRight: 0, bottomRight: 0, topLeft: 0, bottomLeft: 0 };
|
||||
let stableSamples = [];
|
||||
let emptySince = null;
|
||||
let disconnectRequested = false;
|
||||
|
||||
function persistStore() {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const next = {
|
||||
address: store.address || '',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const temporary = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(temporary, STORE_PATH);
|
||||
}
|
||||
|
||||
function round(value, digits = 2) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round((Number(value) || 0) * factor) / factor;
|
||||
function roundedWeight(value) {
|
||||
return Math.round(Math.max(0, Number(value) || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
function normalizeCorners(raw = {}) {
|
||||
// The native bridge reports centi-kilograms because that is the calibrated
|
||||
// unit produced by hid-wiimote. Convert once at the service boundary so every
|
||||
// browser and future event consumer receives ordinary kilograms.
|
||||
return {
|
||||
topRight: Math.max(0, Number(raw.topRight) || 0) / 100,
|
||||
bottomRight: Math.max(0, Number(raw.bottomRight) || 0) / 100,
|
||||
topLeft: Math.max(0, Number(raw.topLeft) || 0) / 100,
|
||||
bottomLeft: Math.max(0, Number(raw.bottomLeft) || 0) / 100,
|
||||
};
|
||||
function rawWeightKg(corners = {}) {
|
||||
// hid-wiimote reports each calibrated load cell in centi-kilograms. The UI
|
||||
// only needs total scale weight, so sum and convert at this single boundary.
|
||||
return ['topRight', 'bottomRight', 'topLeft', 'bottomLeft']
|
||||
.reduce((total, key) => total + Math.max(0, Number(corners[key]) || 0), 0) / 100;
|
||||
}
|
||||
|
||||
function applyTare(corners) {
|
||||
return {
|
||||
topRight: Math.max(0, corners.topRight - tare.topRight),
|
||||
bottomRight: Math.max(0, corners.bottomRight - tare.bottomRight),
|
||||
topLeft: Math.max(0, corners.topLeft - tare.topLeft),
|
||||
bottomLeft: Math.max(0, corners.bottomLeft - tare.bottomLeft),
|
||||
};
|
||||
}
|
||||
|
||||
function describeLoad(corners) {
|
||||
const totalKg = corners.topRight + corners.bottomRight + corners.topLeft + corners.bottomLeft;
|
||||
if (totalKg <= 0.001) return { totalKg: 0, center: { x: 0, y: 0 } };
|
||||
const right = corners.topRight + corners.bottomRight;
|
||||
const left = corners.topLeft + corners.bottomLeft;
|
||||
const top = corners.topRight + corners.topLeft;
|
||||
const bottom = corners.bottomRight + corners.bottomLeft;
|
||||
return {
|
||||
totalKg: round(totalKg, 2),
|
||||
// Normalized -1..1 coordinates make the browser independent of the board's
|
||||
// physical dimensions while retaining the full centering information.
|
||||
center: {
|
||||
x: round((right - left) / totalKg, 3),
|
||||
y: round((bottom - top) / totalKg, 3),
|
||||
},
|
||||
};
|
||||
}
|
||||
let store = enabled ? loadStore() : { address: '' };
|
||||
let hardware = null;
|
||||
let status = enabled ? (store.address ? 'waiting' : 'starting') : 'disabled';
|
||||
let detail = enabled
|
||||
? (store.address ? 'Press the front power button.' : 'Starting Bluetooth discovery.')
|
||||
: 'Balance Board support is disabled.';
|
||||
let connected = false;
|
||||
let batteryPercent = null;
|
||||
let latestFrame = null;
|
||||
let tareSamples = [];
|
||||
let tareKg = 0;
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
enabled,
|
||||
paired: Boolean(store.address) || settings.simulate,
|
||||
address: store.address || (settings.simulate ? 'SIMULATED' : null),
|
||||
paired: Boolean(store.address) || Boolean(rawConfig.simulate),
|
||||
address: store.address || (rawConfig.simulate ? 'SIMULATED' : null),
|
||||
connected,
|
||||
hardwareState,
|
||||
phase,
|
||||
status,
|
||||
detail,
|
||||
batteryPercent,
|
||||
lastError,
|
||||
lastMeasurement,
|
||||
bluetooth,
|
||||
inputState,
|
||||
bluetoothError,
|
||||
inputError,
|
||||
reconnectDetail,
|
||||
diagnosticsUpdatedAt,
|
||||
lastConnectedAt,
|
||||
settings: {
|
||||
minimumWeightKg: settings.minimumWeightKg,
|
||||
stableDurationMs: settings.stableDurationMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emitStateChange(reason) {
|
||||
events.emit('change', { reason, state: getState() });
|
||||
}
|
||||
|
||||
function setPhase(next, reason = next) {
|
||||
if (phase === next) return;
|
||||
phase = next;
|
||||
emitStateChange(reason);
|
||||
}
|
||||
|
||||
function resetMeasurementCycle() {
|
||||
stableSamples = [];
|
||||
emptySince = Date.now();
|
||||
disconnectRequested = false;
|
||||
setPhase('waiting', 'station-empty');
|
||||
}
|
||||
|
||||
function beginTare() {
|
||||
tareSamples = [];
|
||||
stableSamples = [];
|
||||
tare = { topRight: 0, bottomRight: 0, topLeft: 0, bottomLeft: 0 };
|
||||
setPhase('zeroing', 'tare-started');
|
||||
}
|
||||
|
||||
function finishTare() {
|
||||
if (!tareSamples.length) return;
|
||||
const totals = tareSamples.reduce(
|
||||
(sum, sample) => ({
|
||||
topRight: sum.topRight + sample.topRight,
|
||||
bottomRight: sum.bottomRight + sample.bottomRight,
|
||||
topLeft: sum.topLeft + sample.topLeft,
|
||||
bottomLeft: sum.bottomLeft + sample.bottomLeft,
|
||||
}),
|
||||
{ topRight: 0, bottomRight: 0, topLeft: 0, bottomLeft: 0 },
|
||||
);
|
||||
tare = Object.fromEntries(
|
||||
Object.entries(totals).map(([key, value]) => [key, value / tareSamples.length]),
|
||||
);
|
||||
tareSamples = [];
|
||||
resetMeasurementCycle();
|
||||
}
|
||||
|
||||
function isStable(samples) {
|
||||
if (samples.length < 2) return false;
|
||||
const duration = samples[samples.length - 1].ts - samples[0].ts;
|
||||
if (duration < settings.stableDurationMs) return false;
|
||||
const weights = samples.map((sample) => sample.totalKg);
|
||||
if (Math.max(...weights) - Math.min(...weights) > settings.stableToleranceKg) return false;
|
||||
|
||||
const centerXs = samples.map((sample) => sample.center.x);
|
||||
const centerYs = samples.map((sample) => sample.center.y);
|
||||
if (Math.max(...centerXs) - Math.min(...centerXs) > 0.06) return false;
|
||||
if (Math.max(...centerYs) - Math.min(...centerYs) > 0.06) return false;
|
||||
|
||||
// Total weight can remain constant while a rover is still rolling from one
|
||||
// side to the other. Requiring every load cell to settle prevents that motion
|
||||
// from being mistaken for a stable measurement.
|
||||
return ['topRight', 'bottomRight', 'topLeft', 'bottomLeft'].every((corner) => {
|
||||
const values = samples.map((sample) => sample.corners[corner]);
|
||||
return Math.max(...values) - Math.min(...values) <= settings.stableToleranceKg;
|
||||
});
|
||||
}
|
||||
|
||||
function captureMeasurement(frame) {
|
||||
lastMeasurement = {
|
||||
totalKg: frame.totalKg,
|
||||
corners: frame.corners,
|
||||
center: frame.center,
|
||||
capturedAt: frame.ts,
|
||||
};
|
||||
setPhase('captured', 'measurement-captured');
|
||||
publishEvent({
|
||||
source: 'balanceBoard',
|
||||
type: 'balanceBoard.measurement',
|
||||
payload: lastMeasurement,
|
||||
});
|
||||
logger.info('Balance Board measurement captured', {
|
||||
totalKg: lastMeasurement.totalKg,
|
||||
center: lastMeasurement.center,
|
||||
});
|
||||
function updateStatus(nextStatus, nextDetail) {
|
||||
const normalizedStatus = String(nextStatus || 'unknown');
|
||||
const normalizedDetail = String(nextDetail || '');
|
||||
if (status === normalizedStatus && detail === normalizedDetail) return;
|
||||
status = normalizedStatus;
|
||||
detail = normalizedDetail;
|
||||
events.emit('change', { state: getState() });
|
||||
}
|
||||
|
||||
function processFrame(message = {}) {
|
||||
if (!connected) {
|
||||
connected = true;
|
||||
hardwareState = 'connected';
|
||||
lastError = null;
|
||||
beginTare();
|
||||
emitStateChange('hardware-connected');
|
||||
}
|
||||
|
||||
const rawCorners = normalizeCorners(message.corners);
|
||||
const rawLoad = describeLoad(rawCorners);
|
||||
const rawKg = rawWeightKg(message.corners);
|
||||
if (Number.isFinite(Number(message.batteryPercent))) {
|
||||
batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent)));
|
||||
}
|
||||
|
||||
if (phase === 'zeroing') {
|
||||
// Power is normally pressed before a rover approaches, making connection
|
||||
// time the safest automatic zero point. Refuse an obviously loaded board so
|
||||
// a rover already parked on it cannot be silently subtracted as the tare.
|
||||
if (rawLoad.totalKg <= MAX_TARE_WEIGHT_KG) tareSamples.push(rawCorners);
|
||||
if (tareSamples.length >= TARE_SAMPLE_COUNT) finishTare();
|
||||
}
|
||||
|
||||
const corners = applyTare(rawCorners);
|
||||
const load = describeLoad(corners);
|
||||
const frame = {
|
||||
ts: Date.now(),
|
||||
corners: Object.fromEntries(Object.entries(corners).map(([key, value]) => [key, round(value, 2)])),
|
||||
totalKg: load.totalKg,
|
||||
center: load.center,
|
||||
batteryPercent,
|
||||
phase,
|
||||
};
|
||||
latestFrame = frame;
|
||||
io.to(FRAME_ROOM).emit('balanceBoard:frame', frame);
|
||||
|
||||
if (phase === 'zeroing') return;
|
||||
if (frame.totalKg <= settings.exitWeightKg) {
|
||||
if (phase !== 'waiting') resetMeasurementCycle();
|
||||
if (!emptySince) emptySince = frame.ts;
|
||||
if (
|
||||
settings.disconnectWhenEmptyMs > 0 &&
|
||||
!disconnectRequested &&
|
||||
frame.ts - emptySince >= settings.disconnectWhenEmptyMs
|
||||
) {
|
||||
// `disconnect` is advisory: genuine boards normally power down after the
|
||||
// HID connection closes. If a firmware clone ignores it, the station
|
||||
// remains safe and simply continues reporting an empty connected board.
|
||||
disconnectRequested = true;
|
||||
try {
|
||||
hardware?.send('disconnect');
|
||||
} catch (err) {
|
||||
logger.warn('Failed to request Balance Board idle disconnect', err.message);
|
||||
}
|
||||
if (tareSamples.length < TARE_SAMPLE_COUNT && rawKg <= MAX_AUTOMATIC_TARE_KG) {
|
||||
tareSamples.push(rawKg);
|
||||
if (tareSamples.length === TARE_SAMPLE_COUNT) {
|
||||
tareKg = tareSamples.reduce((sum, value) => sum + value, 0) / tareSamples.length;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
emptySince = null;
|
||||
if (phase === 'captured') return;
|
||||
if (frame.totalKg < settings.minimumWeightKg) {
|
||||
stableSamples = [];
|
||||
setPhase('entering', 'load-entering');
|
||||
return;
|
||||
}
|
||||
connected = true;
|
||||
const zeroReady = tareSamples.length >= TARE_SAMPLE_COUNT;
|
||||
updateStatus(
|
||||
zeroReady ? 'connected' : 'zeroing',
|
||||
zeroReady ? 'Live weight is updating.' : 'Keep the board empty for one second while it zeros.',
|
||||
);
|
||||
latestFrame = {
|
||||
totalKg: roundedWeight(rawKg - tareKg),
|
||||
batteryPercent,
|
||||
};
|
||||
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
|
||||
}
|
||||
|
||||
if (phase !== 'stabilizing') setPhase('stabilizing', 'load-detected');
|
||||
stableSamples.push({
|
||||
ts: frame.ts,
|
||||
totalKg: frame.totalKg,
|
||||
center: frame.center,
|
||||
corners: frame.corners,
|
||||
});
|
||||
// Retain one frame of scheduling slack. If we removed everything older than
|
||||
// the exact window first, a 20 Hz stream would usually keep only 1450 ms of
|
||||
// history and could therefore approach but never satisfy a 1500 ms window.
|
||||
const cutoff = frame.ts - settings.stableDurationMs - 100;
|
||||
stableSamples = stableSamples.filter((sample) => sample.ts >= cutoff);
|
||||
if (isStable(stableSamples)) captureMeasurement(frame);
|
||||
function diagnosticDetail(message = {}) {
|
||||
if (message.inputError) return `Input device: ${message.inputError}`;
|
||||
if (message.bluetoothError) return `Bluetooth: ${message.bluetoothError}`;
|
||||
if (message.reconnectDetail) {
|
||||
const reconnectDetail = String(message.reconnectDetail);
|
||||
// bluetoothctl can return a long transcript containing every property
|
||||
// change around one failed attempt. Translate the known Wii HID socket
|
||||
// failure into one useful sentence so the panel remains readable while the
|
||||
// worker continues its automatic retries in the background.
|
||||
if (reconnectDetail.includes('br-connection-create-socket')) {
|
||||
return 'Bluetooth rejected the input connection. The server is retrying automatically.';
|
||||
}
|
||||
return reconnectDetail;
|
||||
}
|
||||
return 'Press the front power button. The server will keep trying to connect.';
|
||||
}
|
||||
|
||||
function handleWorkerMessage(message = {}) {
|
||||
@@ -338,6 +129,7 @@ function handleWorkerMessage(message = {}) {
|
||||
processFrame(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'paired') {
|
||||
const address = typeof message.address === 'string' ? message.address.trim().toUpperCase() : '';
|
||||
if (address && address !== store.address) {
|
||||
@@ -345,67 +137,45 @@ function handleWorkerMessage(message = {}) {
|
||||
persistStore();
|
||||
}
|
||||
hardware?.setAddress(address);
|
||||
hardwareState = 'waiting';
|
||||
lastError = null;
|
||||
setPhase('waiting', 'board-paired');
|
||||
emitStateChange('board-paired');
|
||||
updateStatus('connecting', 'Paired. Connecting to the board now.');
|
||||
return;
|
||||
}
|
||||
if (message.type === 'diagnostics') {
|
||||
// Preserve the three hardware layers independently. A valid BlueZ bond, an
|
||||
// active Bluetooth connection, and a readable evdev device are not
|
||||
// interchangeable and must never collapse back into a single “sleeping”
|
||||
// boolean in the browser.
|
||||
const reportedBluetooth = message.bluetooth || {};
|
||||
bluetooth = {
|
||||
available: Boolean(reportedBluetooth.available),
|
||||
paired: Boolean(reportedBluetooth.paired),
|
||||
trusted: Boolean(reportedBluetooth.trusted),
|
||||
connected: Boolean(reportedBluetooth.connected),
|
||||
wakeAllowed: Boolean(reportedBluetooth.wakeAllowed),
|
||||
};
|
||||
inputState = typeof message.inputState === 'string' ? message.inputState : 'unknown';
|
||||
bluetoothError = message.bluetoothError ? String(message.bluetoothError) : null;
|
||||
inputError = message.inputError ? String(message.inputError) : null;
|
||||
reconnectDetail = message.reconnectDetail ? String(message.reconnectDetail) : null;
|
||||
diagnosticsUpdatedAt = Date.now();
|
||||
emitStateChange('hardware-diagnostics');
|
||||
return;
|
||||
}
|
||||
if (message.type !== 'status') return;
|
||||
|
||||
hardwareState = String(message.state || 'unknown');
|
||||
// Commissioning automatically restarts discovery after a transient BlueZ
|
||||
// failure. Preserve the last actionable error across the following plain
|
||||
// `commissioning` status instead of replacing it with an unhelpful generic
|
||||
// instruction one second later. Any real progress beyond discovery clears it.
|
||||
if (message.error) lastError = String(message.error);
|
||||
else if (hardwareState !== 'commissioning') lastError = null;
|
||||
if (hardwareState === 'connected') {
|
||||
lastConnectedAt = Date.now();
|
||||
if (!connected) {
|
||||
connected = true;
|
||||
beginTare();
|
||||
if (message.type === 'diagnostics') {
|
||||
const bluetoothConnected = Boolean(message.bluetooth?.connected);
|
||||
const inputReady = message.inputState === 'ready';
|
||||
if (bluetoothConnected && inputReady) {
|
||||
updateStatus('connected', 'Connected. Waiting for weight readings.');
|
||||
} else if (bluetoothConnected) {
|
||||
updateStatus('connecting', message.inputError || 'Bluetooth connected. Waiting for the input device.');
|
||||
} else if (store.address) {
|
||||
connected = false;
|
||||
updateStatus('waiting', diagnosticDetail(message));
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== 'status') return;
|
||||
const workerState = String(message.state || 'unknown');
|
||||
if (workerState === 'commissioning') {
|
||||
updateStatus('starting', 'Starting Bluetooth discovery.');
|
||||
} else if (workerState === 'discovering') {
|
||||
updateStatus('waiting-for-sync', 'Press the red Sync button underneath the board.');
|
||||
} else if (workerState === 'pairing') {
|
||||
updateStatus('pairing', 'Board found. Pairing now.');
|
||||
} else if (workerState === 'connected') {
|
||||
connected = true;
|
||||
tareSamples = [];
|
||||
tareKg = 0;
|
||||
updateStatus('zeroing', 'Connected. Keep the board empty for one second while it zeros.');
|
||||
} else if (workerState === 'waiting') {
|
||||
connected = false;
|
||||
latestFrame = null;
|
||||
tareSamples = [];
|
||||
stableSamples = [];
|
||||
if (hardwareState === 'commissioning' || hardwareState === 'pairing') {
|
||||
setPhase(hardwareState, hardwareState);
|
||||
} else if (hardwareState === 'error') {
|
||||
setPhase('error', 'hardware-error');
|
||||
} else {
|
||||
setPhase(store.address || settings.simulate ? 'waiting' : 'commissioning', 'hardware-waiting');
|
||||
}
|
||||
updateStatus('waiting', message.error || 'Press the front power button. The server will keep trying to connect.');
|
||||
} else if (workerState === 'error') {
|
||||
connected = false;
|
||||
updateStatus('error', message.error || 'The Balance Board worker stopped.');
|
||||
}
|
||||
emitStateChange('hardware-status');
|
||||
}
|
||||
|
||||
function requireAdmin(socket) {
|
||||
if (!isAdmin(socket)) throw new Error('Not authorized for Balance Board maintenance');
|
||||
if (!enabled) throw new Error('Balance Board support is disabled');
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
@@ -415,64 +185,13 @@ io.on('connection', (socket) => {
|
||||
cb({ success: true });
|
||||
});
|
||||
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
|
||||
|
||||
socket.on('balanceBoard:tare', (_payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
requireAdmin(socket);
|
||||
if (!connected) throw new Error('Balance Board is not connected');
|
||||
beginTare();
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('balanceBoard:pair', (_payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
requireAdmin(socket);
|
||||
hardware?.send('pair');
|
||||
store.address = '';
|
||||
hardware?.setAddress('');
|
||||
persistStore();
|
||||
setPhase('commissioning', 'pairing-requested');
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('balanceBoard:forget', (_payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
requireAdmin(socket);
|
||||
hardware?.send('forget');
|
||||
store.address = '';
|
||||
hardware?.setAddress('');
|
||||
persistStore();
|
||||
setPhase('commissioning', 'board-forgotten');
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('balanceBoard:restart', (_payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
requireAdmin(socket);
|
||||
hardware?.restart();
|
||||
hardwareState = 'starting';
|
||||
emitStateChange('worker-restarted');
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (enabled) {
|
||||
hardware = createBalanceBoardHardware({
|
||||
logger,
|
||||
address: store.address,
|
||||
simulate: settings.simulate,
|
||||
simulate: Boolean(rawConfig.simulate || process.env.BALANCE_BOARD_SIMULATE),
|
||||
});
|
||||
hardware.events.on('message', handleWorkerMessage);
|
||||
hardware.start();
|
||||
|
||||
@@ -883,50 +883,12 @@ std::optional<std::string> extract_command_value(const std::string& line, const
|
||||
}
|
||||
|
||||
void handle_command(const std::string& line, PairingSharedState* shared) {
|
||||
(void)shared;
|
||||
const std::string command = extract_command_value(line, "command").value_or("");
|
||||
if (command == "pair") {
|
||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
||||
if (!shared->pairing_available) {
|
||||
emit_status("error", "", "Bluetooth pairing capability is unavailable; reinstall the bridge capability");
|
||||
return;
|
||||
}
|
||||
shared->commissioned_address.reset();
|
||||
shared->commissioning = true;
|
||||
} else if (command == "forget") {
|
||||
std::optional<std::string> address;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
||||
if (!shared->pairing_available) {
|
||||
emit_status("error", address.value_or(""),
|
||||
"cannot forget the board while Bluetooth pairing capability is unavailable");
|
||||
return;
|
||||
}
|
||||
address = shared->commissioned_address;
|
||||
shared->commissioned_address.reset();
|
||||
// Do not let the discovery loop race the BlueZ removal. It may otherwise
|
||||
// rediscover and attempt to pair the still-bonded object before `remove`
|
||||
// has finished deleting its keys and cached SDP record.
|
||||
shared->commissioning = false;
|
||||
}
|
||||
if (address.has_value()) run_command({"bluetoothctl", "--timeout", "8", "remove", *address});
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
||||
shared->commissioning = true;
|
||||
}
|
||||
emit_status("commissioning");
|
||||
} else if (command == "disconnect") {
|
||||
std::optional<std::string> address;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
||||
address = shared->commissioned_address;
|
||||
}
|
||||
// Idle disconnect is intentionally host-initiated only after the server has
|
||||
// observed an empty station for its configured delay. The bond remains, so
|
||||
// the next front power-button press still reconnects without commissioning.
|
||||
if (address.has_value()) {
|
||||
run_command({"bluetoothctl", "--timeout", "8", "disconnect", *address});
|
||||
}
|
||||
} else if (command == "stop") {
|
||||
// Pairing and reconnect are deliberately automatic. The only command the
|
||||
// Node supervisor needs is a clean shutdown signal; removing manual pair,
|
||||
// forget, and disconnect modes keeps the hardware flow single-purpose.
|
||||
if (command == "stop") {
|
||||
running.store(false);
|
||||
}
|
||||
}
|
||||
@@ -943,9 +905,8 @@ void simulated_loop() {
|
||||
simulated_bluetooth.trusted = true;
|
||||
simulated_bluetooth.connected = true;
|
||||
simulated_bluetooth.wake_allowed = true;
|
||||
// Exercise the same diagnostics contract as real hardware so development UI
|
||||
// builds cannot silently break the status table merely because CI lacks a
|
||||
// Bluetooth adapter and physical Balance Board.
|
||||
// Exercise the same status contract as real hardware so development UI
|
||||
// builds cannot silently break merely because CI lacks a physical board.
|
||||
emit_diagnostics("SIMULATED", simulated_bluetooth, "ready", "", "");
|
||||
emit_status("waiting", "SIMULATED");
|
||||
const std::array<BoardReadings, 12> sequence{{
|
||||
|
||||
Reference in New Issue
Block a user