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
+2 -16
View File
@@ -178,23 +178,9 @@ kinect:
captureCooldownMs: 10000
balanceBoard:
# After enabling this feature, rerun install_server.sh. The installer then
# configures BlueZ's Wii-compatible HID mode, persistent hid-wiimote loading,
# and the restricted evdev permission automatically.
# The server installer always prepares Bluetooth and the kernel driver. This
# switch only starts the service and shows its small live-weight panel.
enabled: false
# A load must reach this weight before stability timing begins. Keeping the
# threshold above sensor drift prevents an empty board from capturing itself.
minimumWeightKg: 1
# Capture happens automatically after the total weight remains inside the
# tolerance window for this long.
stableDurationMs: 1500
stableToleranceKg: 0.15
# After a captured rover leaves, readings below this threshold reset the
# station for its next visitor.
exitWeightKg: 0.35
# Disconnect an empty board to conserve batteries. Set to 0 to leave the HID
# connection open until the board turns itself off.
disconnectWhenEmptyMs: 30000
buttonBox:
enabled: false
+28 -45
View File
@@ -204,51 +204,38 @@ if [[ ! -f "$CONFIG_PATH" ]]; then
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
fi
# Use the same YAML library as the server instead of approximating nested YAML
# with grep. This makes system configuration follow the exact explicit boolean
# feature flag that the running Node service will use.
BALANCE_BOARD_ENABLED=$(SERVER_DIR="$SERVER_DIR" CONFIG_PATH="$CONFIG_PATH" "$NODE_BIN" <<'NODE'
const fs = require('fs');
const path = require('path');
const yaml = require(path.join(process.env.SERVER_DIR, 'node_modules', 'js-yaml'));
const config = yaml.load(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')) || {};
process.stdout.write(config.balanceBoard?.enabled === true ? 'true' : 'false');
NODE
)
echo " Configuring BlueZ for the Wii Balance Board"
if ! modinfo hid-wiimote >/dev/null 2>&1; then
echo "The running kernel does not provide hid-wiimote; install a Fedora kernel with that module." >&2
exit 1
fi
if [[ "$BALANCE_BOARD_ENABLED" == "true" ]]; then
echo " Configuring BlueZ for the Wii Balance Board"
if ! modinfo hid-wiimote >/dev/null 2>&1; then
echo "The running kernel does not provide hid-wiimote; install a Fedora kernel with that module before enabling balanceBoard." >&2
exit 1
fi
install -d -m 0755 /etc/bluetooth /etc/modules-load.d
touch "$BLUEZ_INPUT_CONFIG"
chmod 0644 "$BLUEZ_INPUT_CONFIG"
# Balance Board support is a server hardware prerequisite, just like the native
# worker and udev rule above. Install it every time instead of coupling machine
# setup to an application setting in config.yaml; that keeps the installer
# deterministic and lets the feature flag remain a simple runtime UI switch.
# crudini changes only the two Wii compatibility keys and preserves every other
# Bluetooth input option already configured by the operator.
crudini --set "$BLUEZ_INPUT_CONFIG" General UserspaceHID false
crudini --set "$BLUEZ_INPUT_CONFIG" General ClassicBondedOnly false
install -d -m 0755 /etc/bluetooth /etc/modules-load.d
touch "$BLUEZ_INPUT_CONFIG"
chmod 0644 "$BLUEZ_INPUT_CONFIG"
# Modern BlueZ defaults Classic HID devices to userspace UHID and enforces a
# security mode that breaks the Wii family's unusual legacy HID handshake.
# crudini changes only these two keys, preserving every unrelated Bluetooth
# input option an operator may already have configured on the server.
crudini --set "$BLUEZ_INPUT_CONFIG" General UserspaceHID false
crudini --set "$BLUEZ_INPUT_CONFIG" General ClassicBondedOnly false
# The service consumes the calibrated evdev axes created specifically by the
# kernel hid-wiimote driver. Load it now and on every future boot so the brief
# board wake window is never lost waiting for manual module setup.
cat > "$BALANCE_BOARD_MODULES_LOAD" <<'EOF'
# The service consumes calibrated evdev axes from the kernel driver. Load the
# driver now and at every boot so a short board wake is never lost while an
# operator manually prepares the server.
cat > "$BALANCE_BOARD_MODULES_LOAD" <<'EOF'
# MultiRoombaRover Wii Balance Board support.
hid-wiimote
EOF
chmod 0644 "$BALANCE_BOARD_MODULES_LOAD"
modprobe hid-wiimote
chmod 0644 "$BALANCE_BOARD_MODULES_LOAD"
modprobe hid-wiimote
# BlueZ reads input.conf only at daemon startup. Restart it during the
# installer, before multirover is restarted below, so the new HID mode is
# guaranteed to be active without requiring a reboot or another command.
systemctl enable --now bluetooth.service
systemctl restart bluetooth.service
fi
# BlueZ reads input.conf only at daemon startup. Restart it before the rover
# service so the required HID mode is active immediately without a reboot.
systemctl enable --now bluetooth.service
systemctl restart bluetooth.service
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
@@ -382,9 +369,5 @@ echo
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
echo "Kinect/libfreenect packages and udev permissions were installed."
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
if [[ "$BALANCE_BOARD_ENABLED" == "true" ]]; then
echo "Wii Balance Board BlueZ compatibility, kernel driver, bridge, and restricted input rule were installed."
echo "Press the red Sync button once to commission it; later wakes use the front power button."
else
echo "Wii Balance Board bridge and restricted input rule were installed but system Bluetooth compatibility was left unchanged because balanceBoard is disabled."
fi
echo "Wii Balance Board Bluetooth support, kernel driver, bridge, and restricted input rule were installed."
echo "Enable balanceBoard in config.yaml, press red Sync once, then use the front button for later wakes."
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BUKu-zuJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BxYDUK4v.css">
<script type="module" crossorigin src="/assets/index-O4nEBzvV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dpy8jv9j.css">
</head>
<body>
<div id="root"></div>
@@ -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();
});
+40 -308
View File
@@ -1,183 +1,47 @@
// Balance Board Panel
// Purpose: Presents the automatic rover weigh-station lifecycle and live four-corner load visualization.
// Scope: Owns feature gating, frame subscription, status copy, centering display, and admin-only maintenance actions.
import { useCallback, useEffect, useMemo, useState } from 'react';
// Purpose: Shows exactly what the Bluetooth board is doing and its current total weight.
// Scope: Owns optional feature gating and the live weight-frame subscription only.
import { useEffect, useState } from 'react';
import { useSocket } from '../../context/SocketContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
import CardFrame from '../CardFrame/index.jsx';
const EMPTY_FRAME = {
totalKg: 0,
corners: {
topLeft: 0,
topRight: 0,
bottomLeft: 0,
bottomRight: 0,
},
center: { x: 0, y: 0 },
batteryPercent: null,
phase: 'waiting',
};
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, Number(value) || 0));
}
const EMPTY_FRAME = { totalKg: 0, batteryPercent: null };
function formatWeight(value) {
const weight = Number(value);
return Number.isFinite(weight) ? `${weight.toFixed(2)} kg` : '0.00 kg';
}
function describePhase(status, frame) {
const phase = frame?.phase || status?.phase || 'waiting';
if (phase === 'error' || status?.lastError) {
const message = status?.lastError || 'The Balance Board bridge reported an error.';
// Name the failed subsystem in the badge. Generic labels such as “Needs
// attention” force an operator to read implementation details to understand
// whether the problem is scanning, pairing, or the sensor bridge itself.
const label = /scanner|discovery/i.test(message)
? 'Bluetooth scanner stopped'
: (/pair/i.test(message) ? 'Board pairing failed' : 'Balance Board error');
return {
label,
instruction: message,
className: 'border-red-500/60 bg-red-950/60 text-red-200',
};
}
if (phase === 'commissioning') {
const scannerReady = status?.hardwareState === 'discovering';
return {
label: scannerReady ? 'Waiting for red Sync' : 'Starting Bluetooth scan',
// Pairing is automatic once discovery is active. State that directly so
// the UI cannot imply that a second software pairing action is required.
instruction: status?.lastError || (scannerReady
? 'Bluetooth is listening. Press the red Sync button underneath the board once.'
: 'The server is starting Bluetooth discovery. Wait for “Waiting for red Sync” before pressing the board button.'),
className: 'border-amber-500/60 bg-amber-950/60 text-amber-200',
};
}
if (phase === 'pairing') {
return {
label: 'Pairing',
instruction: 'Keep the red Sync button active while the server completes the Bluetooth bond.',
className: 'border-sky-500/60 bg-sky-950/60 text-sky-200',
};
}
if (!status?.connected) {
if (status?.bluetooth?.connected) {
return {
label: 'Bluetooth connected, input unavailable',
instruction: status?.inputError || 'The radio link is active, but Linux has not exposed a readable Balance Board input device.',
className: 'border-red-500/60 bg-red-950/60 text-red-200',
};
}
if (status?.paired && !status?.lastConnectedAt) {
return {
label: 'Paired, not connected',
instruction: 'Press the front power button. The server is actively attempting to connect during the blue-light wake window.',
className: 'border-amber-500/60 bg-amber-950/60 text-amber-200',
};
}
return {
label: 'Sleeping',
instruction: 'The previously working board is disconnected. Press its front power button, then drive onto the station.',
className: 'border-slate-600 bg-slate-800 text-slate-200',
};
}
if (phase === 'zeroing') {
return {
label: 'Zeroing',
instruction: 'Keep the board empty while it establishes its resting baseline.',
className: 'border-violet-500/60 bg-violet-950/60 text-violet-200',
};
}
if (phase === 'entering') {
return {
label: 'Approaching',
instruction: 'Continue onto the board until the rovers full weight is supported.',
className: 'border-sky-500/60 bg-sky-950/60 text-sky-200',
};
}
if (phase === 'stabilizing') {
return {
label: 'Hold still',
instruction: 'Center the marker and stop moving while the measurement stabilizes.',
className: 'border-amber-500/60 bg-amber-950/60 text-amber-200',
};
}
if (phase === 'captured') {
return {
label: 'Captured',
instruction: 'Measurement saved. Drive completely off to reset the station.',
className: 'border-emerald-500/60 bg-emerald-950/60 text-emerald-200',
};
}
return {
label: 'Ready',
instruction: 'Drive onto the board. The station will capture a stable weight automatically.',
className: 'border-emerald-500/60 bg-emerald-950/60 text-emerald-200',
};
}
function CornerLoad({ label, value }) {
return (
<div className="rounded border border-slate-600 bg-slate-950/70 px-1 py-0.5 text-center">
<div className="text-[0.62rem] text-slate-400">{label}</div>
<div className="font-mono text-xs font-semibold text-slate-100">{formatWeight(value)}</div>
</div>
);
}
function readableHardwareValue(value) {
if (value == null || value === '') return 'Unknown';
return String(value)
.split('-')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function HardwareStatusRow({ label, value, tone = 'text-slate-200' }) {
return (
<div className="flex min-w-0 items-start justify-between gap-2 border-b border-slate-800 py-0.5 last:border-b-0">
<span className="shrink-0 text-slate-500">{label}</span>
<span className={`min-w-0 break-all text-right font-mono ${tone}`}>{value}</span>
</div>
);
function statusPresentation(value) {
const status = String(value || 'starting');
if (status === 'connected') return { label: 'Connected', tone: 'border-emerald-600 bg-emerald-950 text-emerald-200' };
if (status === 'zeroing') return { label: 'Zeroing', tone: 'border-violet-600 bg-violet-950 text-violet-200' };
if (status === 'pairing') return { label: 'Pairing', tone: 'border-sky-600 bg-sky-950 text-sky-200' };
if (status === 'waiting-for-sync') return { label: 'Waiting for red Sync', tone: 'border-amber-600 bg-amber-950 text-amber-200' };
if (status === 'waiting') return { label: 'Waiting for front button', tone: 'border-amber-600 bg-amber-950 text-amber-200' };
if (status === 'connecting') return { label: 'Connecting', tone: 'border-sky-600 bg-sky-950 text-sky-200' };
if (status === 'error') return { label: 'Error', tone: 'border-red-600 bg-red-950 text-red-200' };
return { label: 'Starting', tone: 'border-slate-600 bg-slate-900 text-slate-200' };
}
export default function BalanceBoardPanel() {
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'balanceBoard'));
/*
Balance Board support is optional physical hardware. Keeping the gate inside
this component lets every Activities layout include the panel without
duplicating config checks or leaving an empty wrapper on disabled servers.
*/
// Keep feature ownership inside the component so layouts do not need special
// cases or empty wrappers when the optional hardware is disabled.
if (!enabled) return null;
return <BalanceBoardPanelContent />;
}
function BalanceBoardPanelContent() {
const socket = useSocket();
const status = useSessionSelector((state) => state.session?.balanceBoard || null);
const role = useSessionSelector((state) => state.session?.role || 'spectator');
const board = useSessionSelector((state) => state.session?.balanceBoard || null);
const [frame, setFrame] = useState(EMPTY_FRAME);
const [actionError, setActionError] = useState('');
const [actionPending, setActionPending] = useState('');
const isAdmin = role === 'admin' || role === 'lockdown';
useEffect(() => {
if (!socket) return undefined;
const handleFrame = (next = {}) => {
setFrame({
...EMPTY_FRAME,
...next,
corners: { ...EMPTY_FRAME.corners, ...(next.corners || {}) },
center: { ...EMPTY_FRAME.center, ...(next.center || {}) },
});
};
const handleFrame = (next = {}) => setFrame({ ...EMPTY_FRAME, ...next });
socket.on('balanceBoard:frame', handleFrame);
socket.emit('balanceBoard:subscribe', {}, () => {});
return () => {
@@ -186,168 +50,36 @@ function BalanceBoardPanelContent() {
};
}, [socket]);
const runAction = useCallback(
(action) => {
if (!socket || actionPending) return;
setActionPending(action);
setActionError('');
socket.emit(`balanceBoard:${action}`, {}, (response = {}) => {
if (response.error) setActionError(response.error);
setActionPending('');
});
},
[actionPending, socket],
);
// Keep the last received frame in state for ordinary socket updates, but mask
// it synchronously whenever hardware disconnects. Deriving the visible value
// avoids both a stale-weight render and an extra effect-driven state update.
const displayFrame = status?.connected ? frame : EMPTY_FRAME;
const presentation = useMemo(() => describePhase(status, displayFrame), [displayFrame, status]);
const centerX = clamp(displayFrame.center?.x, -1, 1);
const centerY = clamp(displayFrame.center?.y, -1, 1);
const markerStyle = {
left: `${50 + centerX * 42}%`,
top: `${50 + centerY * 42}%`,
};
const battery = Number.isFinite(Number(displayFrame.batteryPercent))
? Number(displayFrame.batteryPercent)
: Number.isFinite(Number(status?.batteryPercent))
? Number(status.batteryPercent)
// Mask the previous reading immediately when disconnected. Keeping the last
// socket frame in state avoids effect-driven state resets and stale flashes.
const liveFrame = board?.connected ? frame : EMPTY_FRAME;
const battery = Number.isFinite(Number(liveFrame.batteryPercent))
? Number(liveFrame.batteryPercent)
: Number.isFinite(Number(board?.batteryPercent))
? Number(board.batteryPercent)
: null;
const displayedWeight = status?.connected
? displayFrame.totalKg
: status?.lastMeasurement?.totalKg || 0;
const bondStatus = status?.bluetooth?.paired
? (status?.bluetooth?.trusted ? 'Paired and trusted' : 'Paired, not trusted')
: (status?.paired ? 'Saved, not confirmed' : 'Not paired');
const linkStatus = status?.bluetooth?.connected ? 'Connected' : 'Disconnected';
const diagnosticsTime = Number(status?.diagnosticsUpdatedAt);
const diagnosticsLabel = Number.isFinite(diagnosticsTime)
? new Date(diagnosticsTime).toLocaleTimeString()
: 'Never';
const presentation = statusPresentation(board?.status);
const actions = (
<div className="flex flex-wrap items-center justify-end gap-0.5">
{battery != null ? (
<span className="rounded border border-slate-600 bg-slate-900 px-1 py-0.5 text-[0.65rem] text-slate-300">
Battery {Math.round(battery)}%
</span>
) : null}
<span className={`rounded border px-1 py-0.5 text-[0.65rem] font-semibold ${presentation.className}`}>
{presentation.label}
</span>
</div>
<span className={`rounded border px-1.5 py-0.5 text-xs font-semibold ${presentation.tone}`}>
{presentation.label}
</span>
);
return (
<CardFrame title="Rover Weigh Station" actions={actions} bodyClassName="space-y-1 p-1.5">
<div className="grid gap-1 md:grid-cols-[minmax(0,1fr)_minmax(11rem,0.72fr)]">
<div className="relative aspect-[1.55/1] min-h-[10rem] overflow-hidden rounded-lg border-2 border-slate-500 bg-slate-800 shadow-inner">
{/*
The crosshair and normalized marker make centering readable without
pretending the board can locate a rover in physical centimeters.
The kernel gives load distribution, so -1..1 is the honest unit.
*/}
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-600" />
<div className="absolute inset-y-0 left-1/2 w-px bg-slate-600" />
<div className="absolute left-1/2 top-1/2 h-12 w-12 -translate-x-1/2 -translate-y-1/2 rounded-full border border-dashed border-emerald-400/70" />
<div
className={`absolute z-20 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-lg transition-[left,top] duration-100 ${
displayFrame.totalKg >= Number(status?.settings?.minimumWeightKg || 1)
? 'border-white bg-emerald-400 shadow-emerald-400/50'
: 'border-slate-400 bg-slate-600'
}`}
style={markerStyle}
aria-label="Center of pressure"
/>
<div className="absolute inset-1 grid grid-cols-2 grid-rows-2 gap-1">
<CornerLoad label="Top left" value={displayFrame.corners.topLeft} />
<CornerLoad label="Top right" value={displayFrame.corners.topRight} />
<CornerLoad label="Bottom left" value={displayFrame.corners.bottomLeft} />
<CornerLoad label="Bottom right" value={displayFrame.corners.bottomRight} />
<CardFrame title="Balance Board" actions={actions} bodyClassName="p-2">
<div className="flex min-w-0 items-center justify-between gap-4 rounded border border-slate-700 bg-slate-950/50 p-2">
<div className="min-w-0">
<div className="font-mono text-4xl font-bold leading-none text-white">
{formatWeight(liveFrame.totalKg)}
</div>
<p className="mt-2 break-words text-sm text-slate-300">{board?.detail || 'Starting Balance Board support.'}</p>
{board?.address ? <p className="mt-1 font-mono text-[0.65rem] text-slate-600">{board.address}</p> : null}
</div>
<div className="flex min-w-0 flex-col justify-between gap-1 rounded border border-slate-700 bg-slate-950/50 p-1.5">
<div>
<div className="text-[0.65rem] tracking-wide text-slate-500">Current weight</div>
<div className="font-mono text-3xl font-bold leading-tight text-white">{formatWeight(displayedWeight)}</div>
<p className="mt-1 text-xs leading-snug text-slate-300">{presentation.instruction}</p>
{battery != null ? (
<div className="shrink-0 text-right text-xs text-slate-400">
Battery<br /><span className="font-mono text-base text-slate-200">{Math.round(battery)}%</span>
</div>
<div className="rounded border border-slate-700 bg-slate-950/70 px-1.5 py-1 text-[0.68rem] leading-tight">
{/* These are deliberately literal diagnostics rather than another
synthesized health badge. Operators need to see which exact
layer—bond, radio link, or Linux input—is blocking readings. */}
<HardwareStatusRow label="Address" value={status?.address || 'None'} />
<HardwareStatusRow
label="Bluetooth device"
value={status?.bluetooth?.available ? 'Known to BlueZ' : 'Not available'}
/>
<HardwareStatusRow label="Bluetooth bond" value={bondStatus} />
<HardwareStatusRow
label="Wake reconnect"
value={status?.bluetooth?.wakeAllowed ? 'Allowed' : 'Not allowed'}
tone={status?.bluetooth?.wakeAllowed ? 'text-emerald-300' : 'text-amber-300'}
/>
<HardwareStatusRow
label="Bluetooth link"
value={linkStatus}
tone={status?.bluetooth?.connected ? 'text-emerald-300' : 'text-amber-300'}
/>
<HardwareStatusRow
label="Input device"
value={readableHardwareValue(status?.inputState)}
tone={status?.inputState === 'ready' ? 'text-emerald-300' : 'text-amber-300'}
/>
<HardwareStatusRow label="Worker" value={readableHardwareValue(status?.hardwareState)} />
<HardwareStatusRow label="Status updated" value={diagnosticsLabel} />
{status?.reconnectDetail ? (
<p className="mt-1 break-words border-t border-slate-700 pt-1 text-amber-200">
Last reconnect: {status.reconnectDetail}
</p>
) : null}
{status?.bluetoothError ? <p className="mt-1 break-words text-red-300">Bluetooth: {status.bluetoothError}</p> : null}
{status?.inputError ? <p className="mt-1 break-words text-red-300">Input: {status.inputError}</p> : null}
</div>
{status?.lastMeasurement ? (
<div className="rounded border border-emerald-700/60 bg-emerald-950/30 p-1 text-xs text-emerald-200">
Last captured: <strong>{formatWeight(status.lastMeasurement.totalKg)}</strong>
</div>
) : null}
{isAdmin ? (
<div className="border-t border-slate-700 pt-1">
<div className="mb-0.5 text-[0.62rem] text-slate-500">Admin maintenance</div>
<div className="flex flex-wrap gap-0.5">
{/* An unpaired server scans automatically. A separate “Pair
board” action was redundant and incorrectly suggested that
commissioning required two software/physical pair steps. */}
<button type="button" className="button-dark px-1 py-0.5 text-xs" disabled={Boolean(actionPending) || !status?.connected} onClick={() => runAction('tare')}>
Tare
</button>
<button type="button" className="button-dark px-1 py-0.5 text-xs" disabled={Boolean(actionPending)} onClick={() => runAction('restart')}>
Restart bridge
</button>
{status?.paired ? (
<button
type="button"
className="rounded border border-red-700 bg-red-950/60 px-1 py-0.5 text-xs text-red-200 hover:bg-red-900/70 disabled:opacity-50"
disabled={Boolean(actionPending)}
onClick={() => {
if (window.confirm('Forget the paired Balance Board and return to commissioning mode?')) runAction('forget');
}}
>
Forget board
</button>
) : null}
</div>
{actionError ? <p className="mt-0.5 text-[0.68rem] text-red-300">{actionError}</p> : null}
</div>
) : null}
</div>
) : null}
</div>
</CardFrame>
);