mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
slop
This commit is contained in:
@@ -178,23 +178,9 @@ kinect:
|
|||||||
captureCooldownMs: 10000
|
captureCooldownMs: 10000
|
||||||
|
|
||||||
balanceBoard:
|
balanceBoard:
|
||||||
# After enabling this feature, rerun install_server.sh. The installer then
|
# The server installer always prepares Bluetooth and the kernel driver. This
|
||||||
# configures BlueZ's Wii-compatible HID mode, persistent hid-wiimote loading,
|
# switch only starts the service and shows its small live-weight panel.
|
||||||
# and the restricted evdev permission automatically.
|
|
||||||
enabled: false
|
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:
|
buttonBox:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
+28
-45
@@ -204,51 +204,38 @@ if [[ ! -f "$CONFIG_PATH" ]]; then
|
|||||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Use the same YAML library as the server instead of approximating nested YAML
|
echo " Configuring BlueZ for the Wii Balance Board"
|
||||||
# with grep. This makes system configuration follow the exact explicit boolean
|
if ! modinfo hid-wiimote >/dev/null 2>&1; then
|
||||||
# feature flag that the running Node service will use.
|
echo "The running kernel does not provide hid-wiimote; install a Fedora kernel with that module." >&2
|
||||||
BALANCE_BOARD_ENABLED=$(SERVER_DIR="$SERVER_DIR" CONFIG_PATH="$CONFIG_PATH" "$NODE_BIN" <<'NODE'
|
exit 1
|
||||||
const fs = require('fs');
|
fi
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "$BALANCE_BOARD_ENABLED" == "true" ]]; then
|
install -d -m 0755 /etc/bluetooth /etc/modules-load.d
|
||||||
echo " Configuring BlueZ for the Wii Balance Board"
|
touch "$BLUEZ_INPUT_CONFIG"
|
||||||
if ! modinfo hid-wiimote >/dev/null 2>&1; then
|
chmod 0644 "$BLUEZ_INPUT_CONFIG"
|
||||||
echo "The running kernel does not provide hid-wiimote; install a Fedora kernel with that module before enabling balanceBoard." >&2
|
# Balance Board support is a server hardware prerequisite, just like the native
|
||||||
exit 1
|
# worker and udev rule above. Install it every time instead of coupling machine
|
||||||
fi
|
# 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
|
# The service consumes calibrated evdev axes from the kernel driver. Load the
|
||||||
touch "$BLUEZ_INPUT_CONFIG"
|
# driver now and at every boot so a short board wake is never lost while an
|
||||||
chmod 0644 "$BLUEZ_INPUT_CONFIG"
|
# operator manually prepares the server.
|
||||||
# Modern BlueZ defaults Classic HID devices to userspace UHID and enforces a
|
cat > "$BALANCE_BOARD_MODULES_LOAD" <<'EOF'
|
||||||
# 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'
|
|
||||||
# MultiRoombaRover Wii Balance Board support.
|
# MultiRoombaRover Wii Balance Board support.
|
||||||
hid-wiimote
|
hid-wiimote
|
||||||
EOF
|
EOF
|
||||||
chmod 0644 "$BALANCE_BOARD_MODULES_LOAD"
|
chmod 0644 "$BALANCE_BOARD_MODULES_LOAD"
|
||||||
modprobe hid-wiimote
|
modprobe hid-wiimote
|
||||||
|
|
||||||
# BlueZ reads input.conf only at daemon startup. Restart it during the
|
# BlueZ reads input.conf only at daemon startup. Restart it before the rover
|
||||||
# installer, before multirover is restarted below, so the new HID mode is
|
# service so the required HID mode is active immediately without a reboot.
|
||||||
# guaranteed to be active without requiring a reboot or another command.
|
systemctl enable --now bluetooth.service
|
||||||
systemctl enable --now bluetooth.service
|
systemctl restart bluetooth.service
|
||||||
systemctl restart bluetooth.service
|
|
||||||
fi
|
|
||||||
|
|
||||||
tmpdir=$(mktemp -d)
|
tmpdir=$(mktemp -d)
|
||||||
trap 'rm -rf "$tmpdir"' EXIT
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
@@ -382,9 +369,5 @@ echo
|
|||||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||||
echo "Kinect/libfreenect packages and udev permissions were installed."
|
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."
|
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 Bluetooth support, kernel driver, bridge, and restricted input rule were installed."
|
||||||
echo "Wii Balance Board BlueZ compatibility, 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."
|
||||||
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
|
|
||||||
|
|||||||
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
+1
-1
File diff suppressed because one or more lines are too long
@@ -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/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>
|
<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>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BUKu-zuJ.js"></script>
|
<script type="module" crossorigin src="/assets/index-O4nEBzvV.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BxYDUK4v.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Dpy8jv9j.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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
|
// Balance Board Hardware Bridge
|
||||||
// Purpose: Supervises the capability-limited native worker and converts its JSON-line protocol into service events.
|
// 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 { spawn } = require('child_process');
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const path = require('path');
|
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() {
|
function stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
if (restartTimer) {
|
if (restartTimer) {
|
||||||
@@ -139,28 +129,10 @@ function createBalanceBoardHardware({ logger, address = '', simulate = false } =
|
|||||||
}, 1500).unref();
|
}, 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 {
|
return {
|
||||||
events,
|
events,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
restart,
|
|
||||||
send,
|
|
||||||
setAddress(nextAddress) {
|
setAddress(nextAddress) {
|
||||||
// The factory can be created before first commissioning. Preserve the
|
// The factory can be created before first commissioning. Preserve the
|
||||||
// newly paired address for later bridge restarts in the same Node process
|
// newly paired address for later bridge restarts in the same Node process
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Balance Board Service
|
// Balance Board Service
|
||||||
// Purpose: Turns calibrated corner loads into an automatic rover weigh-station lifecycle.
|
// Purpose: Exposes one Wii Balance Board as a self-pairing Bluetooth scale.
|
||||||
// Scope: Owns configuration, tare/stability policy, session state, Socket.IO delivery, persistence, and admin maintenance actions.
|
// Scope: Stores the paired address, applies a small automatic tare, and publishes simple status plus live weight.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
@@ -8,48 +8,16 @@ const logger = require('../../globals/logger').child('balanceBoardService');
|
|||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
const { isFeatureEnabled } = require('../../helpers/features');
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
const { isAdmin } = require('../roleService');
|
|
||||||
const { publishEvent } = require('../eventBus');
|
|
||||||
const { createBalanceBoardHardware } = require('./hardware');
|
const { createBalanceBoardHardware } = require('./hardware');
|
||||||
|
|
||||||
const events = new EventEmitter();
|
const events = new EventEmitter();
|
||||||
const config = loadConfig();
|
|
||||||
const rawConfig = config.balanceBoard || {};
|
|
||||||
const enabled = isFeatureEnabled('balanceBoard');
|
const enabled = isFeatureEnabled('balanceBoard');
|
||||||
|
const rawConfig = loadConfig().balanceBoard || {};
|
||||||
const DATA_DIR = resolveDataDir();
|
const DATA_DIR = resolveDataDir();
|
||||||
const STORE_PATH = resolveDataPath('balance-board.json');
|
const STORE_PATH = resolveDataPath('balance-board.json');
|
||||||
const FRAME_ROOM = 'balance-board-viewers';
|
const FRAME_ROOM = 'balance-board-viewers';
|
||||||
const TARE_SAMPLE_COUNT = 20;
|
const TARE_SAMPLE_COUNT = 20;
|
||||||
const MAX_TARE_WEIGHT_KG = 2;
|
const MAX_AUTOMATIC_TARE_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),
|
|
||||||
};
|
|
||||||
|
|
||||||
function loadStore() {
|
function loadStore() {
|
||||||
try {
|
try {
|
||||||
@@ -57,280 +25,103 @@ function loadStore() {
|
|||||||
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
|
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
|
||||||
return { address };
|
return { address };
|
||||||
} catch (err) {
|
} 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: '' };
|
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() {
|
function persistStore() {
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
const next = {
|
|
||||||
address: store.address || '',
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
const temporary = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
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);
|
fs.renameSync(temporary, STORE_PATH);
|
||||||
}
|
}
|
||||||
|
|
||||||
function round(value, digits = 2) {
|
function roundedWeight(value) {
|
||||||
const factor = 10 ** digits;
|
return Math.round(Math.max(0, Number(value) || 0) * 100) / 100;
|
||||||
return Math.round((Number(value) || 0) * factor) / factor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCorners(raw = {}) {
|
function rawWeightKg(corners = {}) {
|
||||||
// The native bridge reports centi-kilograms because that is the calibrated
|
// hid-wiimote reports each calibrated load cell in centi-kilograms. The UI
|
||||||
// unit produced by hid-wiimote. Convert once at the service boundary so every
|
// only needs total scale weight, so sum and convert at this single boundary.
|
||||||
// browser and future event consumer receives ordinary kilograms.
|
return ['topRight', 'bottomRight', 'topLeft', 'bottomLeft']
|
||||||
return {
|
.reduce((total, key) => total + Math.max(0, Number(corners[key]) || 0), 0) / 100;
|
||||||
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 applyTare(corners) {
|
let store = enabled ? loadStore() : { address: '' };
|
||||||
return {
|
let hardware = null;
|
||||||
topRight: Math.max(0, corners.topRight - tare.topRight),
|
let status = enabled ? (store.address ? 'waiting' : 'starting') : 'disabled';
|
||||||
bottomRight: Math.max(0, corners.bottomRight - tare.bottomRight),
|
let detail = enabled
|
||||||
topLeft: Math.max(0, corners.topLeft - tare.topLeft),
|
? (store.address ? 'Press the front power button.' : 'Starting Bluetooth discovery.')
|
||||||
bottomLeft: Math.max(0, corners.bottomLeft - tare.bottomLeft),
|
: 'Balance Board support is disabled.';
|
||||||
};
|
let connected = false;
|
||||||
}
|
let batteryPercent = null;
|
||||||
|
let latestFrame = null;
|
||||||
function describeLoad(corners) {
|
let tareSamples = [];
|
||||||
const totalKg = corners.topRight + corners.bottomRight + corners.topLeft + corners.bottomLeft;
|
let tareKg = 0;
|
||||||
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),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getState() {
|
function getState() {
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
paired: Boolean(store.address) || settings.simulate,
|
paired: Boolean(store.address) || Boolean(rawConfig.simulate),
|
||||||
address: store.address || (settings.simulate ? 'SIMULATED' : null),
|
address: store.address || (rawConfig.simulate ? 'SIMULATED' : null),
|
||||||
connected,
|
connected,
|
||||||
hardwareState,
|
status,
|
||||||
phase,
|
detail,
|
||||||
batteryPercent,
|
batteryPercent,
|
||||||
lastError,
|
|
||||||
lastMeasurement,
|
|
||||||
bluetooth,
|
|
||||||
inputState,
|
|
||||||
bluetoothError,
|
|
||||||
inputError,
|
|
||||||
reconnectDetail,
|
|
||||||
diagnosticsUpdatedAt,
|
|
||||||
lastConnectedAt,
|
|
||||||
settings: {
|
|
||||||
minimumWeightKg: settings.minimumWeightKg,
|
|
||||||
stableDurationMs: settings.stableDurationMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitStateChange(reason) {
|
function updateStatus(nextStatus, nextDetail) {
|
||||||
events.emit('change', { reason, state: getState() });
|
const normalizedStatus = String(nextStatus || 'unknown');
|
||||||
}
|
const normalizedDetail = String(nextDetail || '');
|
||||||
|
if (status === normalizedStatus && detail === normalizedDetail) return;
|
||||||
function setPhase(next, reason = next) {
|
status = normalizedStatus;
|
||||||
if (phase === next) return;
|
detail = normalizedDetail;
|
||||||
phase = next;
|
events.emit('change', { state: getState() });
|
||||||
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 processFrame(message = {}) {
|
function processFrame(message = {}) {
|
||||||
if (!connected) {
|
const rawKg = rawWeightKg(message.corners);
|
||||||
connected = true;
|
|
||||||
hardwareState = 'connected';
|
|
||||||
lastError = null;
|
|
||||||
beginTare();
|
|
||||||
emitStateChange('hardware-connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawCorners = normalizeCorners(message.corners);
|
|
||||||
const rawLoad = describeLoad(rawCorners);
|
|
||||||
if (Number.isFinite(Number(message.batteryPercent))) {
|
if (Number.isFinite(Number(message.batteryPercent))) {
|
||||||
batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent)));
|
batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === 'zeroing') {
|
if (tareSamples.length < TARE_SAMPLE_COUNT && rawKg <= MAX_AUTOMATIC_TARE_KG) {
|
||||||
// Power is normally pressed before a rover approaches, making connection
|
tareSamples.push(rawKg);
|
||||||
// time the safest automatic zero point. Refuse an obviously loaded board so
|
if (tareSamples.length === TARE_SAMPLE_COUNT) {
|
||||||
// a rover already parked on it cannot be silently subtracted as the tare.
|
tareKg = tareSamples.reduce((sum, value) => sum + value, 0) / tareSamples.length;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emptySince = null;
|
connected = true;
|
||||||
if (phase === 'captured') return;
|
const zeroReady = tareSamples.length >= TARE_SAMPLE_COUNT;
|
||||||
if (frame.totalKg < settings.minimumWeightKg) {
|
updateStatus(
|
||||||
stableSamples = [];
|
zeroReady ? 'connected' : 'zeroing',
|
||||||
setPhase('entering', 'load-entering');
|
zeroReady ? 'Live weight is updating.' : 'Keep the board empty for one second while it zeros.',
|
||||||
return;
|
);
|
||||||
}
|
latestFrame = {
|
||||||
|
totalKg: roundedWeight(rawKg - tareKg),
|
||||||
|
batteryPercent,
|
||||||
|
};
|
||||||
|
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
|
||||||
|
}
|
||||||
|
|
||||||
if (phase !== 'stabilizing') setPhase('stabilizing', 'load-detected');
|
function diagnosticDetail(message = {}) {
|
||||||
stableSamples.push({
|
if (message.inputError) return `Input device: ${message.inputError}`;
|
||||||
ts: frame.ts,
|
if (message.bluetoothError) return `Bluetooth: ${message.bluetoothError}`;
|
||||||
totalKg: frame.totalKg,
|
if (message.reconnectDetail) {
|
||||||
center: frame.center,
|
const reconnectDetail = String(message.reconnectDetail);
|
||||||
corners: frame.corners,
|
// bluetoothctl can return a long transcript containing every property
|
||||||
});
|
// change around one failed attempt. Translate the known Wii HID socket
|
||||||
// Retain one frame of scheduling slack. If we removed everything older than
|
// failure into one useful sentence so the panel remains readable while the
|
||||||
// the exact window first, a 20 Hz stream would usually keep only 1450 ms of
|
// worker continues its automatic retries in the background.
|
||||||
// history and could therefore approach but never satisfy a 1500 ms window.
|
if (reconnectDetail.includes('br-connection-create-socket')) {
|
||||||
const cutoff = frame.ts - settings.stableDurationMs - 100;
|
return 'Bluetooth rejected the input connection. The server is retrying automatically.';
|
||||||
stableSamples = stableSamples.filter((sample) => sample.ts >= cutoff);
|
}
|
||||||
if (isStable(stableSamples)) captureMeasurement(frame);
|
return reconnectDetail;
|
||||||
|
}
|
||||||
|
return 'Press the front power button. The server will keep trying to connect.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWorkerMessage(message = {}) {
|
function handleWorkerMessage(message = {}) {
|
||||||
@@ -338,6 +129,7 @@ function handleWorkerMessage(message = {}) {
|
|||||||
processFrame(message);
|
processFrame(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'paired') {
|
if (message.type === 'paired') {
|
||||||
const address = typeof message.address === 'string' ? message.address.trim().toUpperCase() : '';
|
const address = typeof message.address === 'string' ? message.address.trim().toUpperCase() : '';
|
||||||
if (address && address !== store.address) {
|
if (address && address !== store.address) {
|
||||||
@@ -345,67 +137,45 @@ function handleWorkerMessage(message = {}) {
|
|||||||
persistStore();
|
persistStore();
|
||||||
}
|
}
|
||||||
hardware?.setAddress(address);
|
hardware?.setAddress(address);
|
||||||
hardwareState = 'waiting';
|
updateStatus('connecting', 'Paired. Connecting to the board now.');
|
||||||
lastError = null;
|
|
||||||
setPhase('waiting', 'board-paired');
|
|
||||||
emitStateChange('board-paired');
|
|
||||||
return;
|
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');
|
if (message.type === 'diagnostics') {
|
||||||
// Commissioning automatically restarts discovery after a transient BlueZ
|
const bluetoothConnected = Boolean(message.bluetooth?.connected);
|
||||||
// failure. Preserve the last actionable error across the following plain
|
const inputReady = message.inputState === 'ready';
|
||||||
// `commissioning` status instead of replacing it with an unhelpful generic
|
if (bluetoothConnected && inputReady) {
|
||||||
// instruction one second later. Any real progress beyond discovery clears it.
|
updateStatus('connected', 'Connected. Waiting for weight readings.');
|
||||||
if (message.error) lastError = String(message.error);
|
} else if (bluetoothConnected) {
|
||||||
else if (hardwareState !== 'commissioning') lastError = null;
|
updateStatus('connecting', message.inputError || 'Bluetooth connected. Waiting for the input device.');
|
||||||
if (hardwareState === 'connected') {
|
} else if (store.address) {
|
||||||
lastConnectedAt = Date.now();
|
connected = false;
|
||||||
if (!connected) {
|
updateStatus('waiting', diagnosticDetail(message));
|
||||||
connected = true;
|
|
||||||
beginTare();
|
|
||||||
}
|
}
|
||||||
} 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;
|
connected = false;
|
||||||
latestFrame = null;
|
latestFrame = null;
|
||||||
tareSamples = [];
|
updateStatus('waiting', message.error || 'Press the front power button. The server will keep trying to connect.');
|
||||||
stableSamples = [];
|
} else if (workerState === 'error') {
|
||||||
if (hardwareState === 'commissioning' || hardwareState === 'pairing') {
|
connected = false;
|
||||||
setPhase(hardwareState, hardwareState);
|
updateStatus('error', message.error || 'The Balance Board worker stopped.');
|
||||||
} else if (hardwareState === 'error') {
|
|
||||||
setPhase('error', 'hardware-error');
|
|
||||||
} else {
|
|
||||||
setPhase(store.address || settings.simulate ? 'waiting' : 'commissioning', 'hardware-waiting');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) => {
|
io.on('connection', (socket) => {
|
||||||
@@ -415,64 +185,13 @@ io.on('connection', (socket) => {
|
|||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
});
|
});
|
||||||
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
|
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) {
|
if (enabled) {
|
||||||
hardware = createBalanceBoardHardware({
|
hardware = createBalanceBoardHardware({
|
||||||
logger,
|
logger,
|
||||||
address: store.address,
|
address: store.address,
|
||||||
simulate: settings.simulate,
|
simulate: Boolean(rawConfig.simulate || process.env.BALANCE_BOARD_SIMULATE),
|
||||||
});
|
});
|
||||||
hardware.events.on('message', handleWorkerMessage);
|
hardware.events.on('message', handleWorkerMessage);
|
||||||
hardware.start();
|
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 handle_command(const std::string& line, PairingSharedState* shared) {
|
||||||
|
(void)shared;
|
||||||
const std::string command = extract_command_value(line, "command").value_or("");
|
const std::string command = extract_command_value(line, "command").value_or("");
|
||||||
if (command == "pair") {
|
// Pairing and reconnect are deliberately automatic. The only command the
|
||||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
// Node supervisor needs is a clean shutdown signal; removing manual pair,
|
||||||
if (!shared->pairing_available) {
|
// forget, and disconnect modes keeps the hardware flow single-purpose.
|
||||||
emit_status("error", "", "Bluetooth pairing capability is unavailable; reinstall the bridge capability");
|
if (command == "stop") {
|
||||||
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") {
|
|
||||||
running.store(false);
|
running.store(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -943,9 +905,8 @@ void simulated_loop() {
|
|||||||
simulated_bluetooth.trusted = true;
|
simulated_bluetooth.trusted = true;
|
||||||
simulated_bluetooth.connected = true;
|
simulated_bluetooth.connected = true;
|
||||||
simulated_bluetooth.wake_allowed = true;
|
simulated_bluetooth.wake_allowed = true;
|
||||||
// Exercise the same diagnostics contract as real hardware so development UI
|
// Exercise the same status contract as real hardware so development UI
|
||||||
// builds cannot silently break the status table merely because CI lacks a
|
// builds cannot silently break merely because CI lacks a physical board.
|
||||||
// Bluetooth adapter and physical Balance Board.
|
|
||||||
emit_diagnostics("SIMULATED", simulated_bluetooth, "ready", "", "");
|
emit_diagnostics("SIMULATED", simulated_bluetooth, "ready", "", "");
|
||||||
emit_status("waiting", "SIMULATED");
|
emit_status("waiting", "SIMULATED");
|
||||||
const std::array<BoardReadings, 12> sequence{{
|
const std::array<BoardReadings, 12> sequence{{
|
||||||
|
|||||||
@@ -403,9 +403,9 @@ kinectEvents.on('change', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
balanceBoardEvents.on('change', () => {
|
balanceBoardEvents.on('change', () => {
|
||||||
// Live load frames use their own Socket.IO room. Only lifecycle transitions
|
// Live weight frames use their own Socket.IO room because they change much
|
||||||
// and captured results reach this listener, keeping session sync inexpensive
|
// faster than the full session. Only connection/status changes reach this
|
||||||
// while still making reconnects and completed measurements durable UI state.
|
// listener, keeping session sync inexpensive while the panel stays current.
|
||||||
logger.info('Balance Board state change; syncing all clients');
|
logger.info('Balance Board state change; syncing all clients');
|
||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,183 +1,47 @@
|
|||||||
// Balance Board Panel
|
// Balance Board Panel
|
||||||
// Purpose: Presents the automatic rover weigh-station lifecycle and live four-corner load visualization.
|
// Purpose: Shows exactly what the Bluetooth board is doing and its current total weight.
|
||||||
// Scope: Owns feature gating, frame subscription, status copy, centering display, and admin-only maintenance actions.
|
// Scope: Owns optional feature gating and the live weight-frame subscription only.
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSocket } from '../../context/SocketContext.jsx';
|
import { useSocket } from '../../context/SocketContext.jsx';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { isFeatureEnabled } from '../../lib/features.js';
|
import { isFeatureEnabled } from '../../lib/features.js';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
const EMPTY_FRAME = {
|
const EMPTY_FRAME = { totalKg: 0, batteryPercent: null };
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatWeight(value) {
|
function formatWeight(value) {
|
||||||
const weight = Number(value);
|
const weight = Number(value);
|
||||||
return Number.isFinite(weight) ? `${weight.toFixed(2)} kg` : '0.00 kg';
|
return Number.isFinite(weight) ? `${weight.toFixed(2)} kg` : '0.00 kg';
|
||||||
}
|
}
|
||||||
|
|
||||||
function describePhase(status, frame) {
|
function statusPresentation(value) {
|
||||||
const phase = frame?.phase || status?.phase || 'waiting';
|
const status = String(value || 'starting');
|
||||||
if (phase === 'error' || status?.lastError) {
|
if (status === 'connected') return { label: 'Connected', tone: 'border-emerald-600 bg-emerald-950 text-emerald-200' };
|
||||||
const message = status?.lastError || 'The Balance Board bridge reported an error.';
|
if (status === 'zeroing') return { label: 'Zeroing', tone: 'border-violet-600 bg-violet-950 text-violet-200' };
|
||||||
// Name the failed subsystem in the badge. Generic labels such as “Needs
|
if (status === 'pairing') return { label: 'Pairing', tone: 'border-sky-600 bg-sky-950 text-sky-200' };
|
||||||
// attention” force an operator to read implementation details to understand
|
if (status === 'waiting-for-sync') return { label: 'Waiting for red Sync', tone: 'border-amber-600 bg-amber-950 text-amber-200' };
|
||||||
// whether the problem is scanning, pairing, or the sensor bridge itself.
|
if (status === 'waiting') return { label: 'Waiting for front button', tone: 'border-amber-600 bg-amber-950 text-amber-200' };
|
||||||
const label = /scanner|discovery/i.test(message)
|
if (status === 'connecting') return { label: 'Connecting', tone: 'border-sky-600 bg-sky-950 text-sky-200' };
|
||||||
? 'Bluetooth scanner stopped'
|
if (status === 'error') return { label: 'Error', tone: 'border-red-600 bg-red-950 text-red-200' };
|
||||||
: (/pair/i.test(message) ? 'Board pairing failed' : 'Balance Board error');
|
return { label: 'Starting', tone: 'border-slate-600 bg-slate-900 text-slate-200' };
|
||||||
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 rover’s 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BalanceBoardPanel() {
|
export default function BalanceBoardPanel() {
|
||||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'balanceBoard'));
|
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'balanceBoard'));
|
||||||
|
// Keep feature ownership inside the component so layouts do not need special
|
||||||
/*
|
// cases or empty wrappers when the optional hardware is disabled.
|
||||||
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.
|
|
||||||
*/
|
|
||||||
if (!enabled) return null;
|
if (!enabled) return null;
|
||||||
return <BalanceBoardPanelContent />;
|
return <BalanceBoardPanelContent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BalanceBoardPanelContent() {
|
function BalanceBoardPanelContent() {
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const status = useSessionSelector((state) => state.session?.balanceBoard || null);
|
const board = useSessionSelector((state) => state.session?.balanceBoard || null);
|
||||||
const role = useSessionSelector((state) => state.session?.role || 'spectator');
|
|
||||||
const [frame, setFrame] = useState(EMPTY_FRAME);
|
const [frame, setFrame] = useState(EMPTY_FRAME);
|
||||||
const [actionError, setActionError] = useState('');
|
|
||||||
const [actionPending, setActionPending] = useState('');
|
|
||||||
const isAdmin = role === 'admin' || role === 'lockdown';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return undefined;
|
if (!socket) return undefined;
|
||||||
const handleFrame = (next = {}) => {
|
const handleFrame = (next = {}) => setFrame({ ...EMPTY_FRAME, ...next });
|
||||||
setFrame({
|
|
||||||
...EMPTY_FRAME,
|
|
||||||
...next,
|
|
||||||
corners: { ...EMPTY_FRAME.corners, ...(next.corners || {}) },
|
|
||||||
center: { ...EMPTY_FRAME.center, ...(next.center || {}) },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
socket.on('balanceBoard:frame', handleFrame);
|
socket.on('balanceBoard:frame', handleFrame);
|
||||||
socket.emit('balanceBoard:subscribe', {}, () => {});
|
socket.emit('balanceBoard:subscribe', {}, () => {});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -186,168 +50,36 @@ function BalanceBoardPanelContent() {
|
|||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
const runAction = useCallback(
|
// Mask the previous reading immediately when disconnected. Keeping the last
|
||||||
(action) => {
|
// socket frame in state avoids effect-driven state resets and stale flashes.
|
||||||
if (!socket || actionPending) return;
|
const liveFrame = board?.connected ? frame : EMPTY_FRAME;
|
||||||
setActionPending(action);
|
const battery = Number.isFinite(Number(liveFrame.batteryPercent))
|
||||||
setActionError('');
|
? Number(liveFrame.batteryPercent)
|
||||||
socket.emit(`balanceBoard:${action}`, {}, (response = {}) => {
|
: Number.isFinite(Number(board?.batteryPercent))
|
||||||
if (response.error) setActionError(response.error);
|
? Number(board.batteryPercent)
|
||||||
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)
|
|
||||||
: null;
|
: null;
|
||||||
const displayedWeight = status?.connected
|
const presentation = statusPresentation(board?.status);
|
||||||
? 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 actions = (
|
const actions = (
|
||||||
<div className="flex flex-wrap items-center justify-end gap-0.5">
|
<span className={`rounded border px-1.5 py-0.5 text-xs font-semibold ${presentation.tone}`}>
|
||||||
{battery != null ? (
|
{presentation.label}
|
||||||
<span className="rounded border border-slate-600 bg-slate-900 px-1 py-0.5 text-[0.65rem] text-slate-300">
|
</span>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Rover Weigh Station" actions={actions} bodyClassName="space-y-1 p-1.5">
|
<CardFrame title="Balance Board" actions={actions} bodyClassName="p-2">
|
||||||
<div className="grid gap-1 md:grid-cols-[minmax(0,1fr)_minmax(11rem,0.72fr)]">
|
<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="relative aspect-[1.55/1] min-h-[10rem] overflow-hidden rounded-lg border-2 border-slate-500 bg-slate-800 shadow-inner">
|
<div className="min-w-0">
|
||||||
{/*
|
<div className="font-mono text-4xl font-bold leading-none text-white">
|
||||||
The crosshair and normalized marker make centering readable without
|
{formatWeight(liveFrame.totalKg)}
|
||||||
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} />
|
|
||||||
</div>
|
</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>
|
||||||
|
{battery != null ? (
|
||||||
<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 className="shrink-0 text-right text-xs text-slate-400">
|
||||||
<div>
|
Battery<br /><span className="font-mono text-base text-slate-200">{Math.round(battery)}%</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user