This commit is contained in:
legop3
2026-07-16 22:52:03 -04:00
parent 35561495b4
commit 4bd228547a
13 changed files with 196 additions and 920 deletions
@@ -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
+101 -382
View File
@@ -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{{
+3 -3
View File
@@ -403,9 +403,9 @@ kinectEvents.on('change', () => {
});
balanceBoardEvents.on('change', () => {
// Live load frames use their own Socket.IO room. Only lifecycle transitions
// and captured results reach this listener, keeping session sync inexpensive
// while still making reconnects and completed measurements durable UI state.
// Live weight frames use their own Socket.IO room because they change much
// faster than the full session. Only connection/status changes reach this
// listener, keeping session sync inexpensive while the panel stays current.
logger.info('Balance Board state change; syncing all clients');
syncAll();
});