mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
Merge branch 'main' of https://github.com/legop3/MultiRoombaRover
This commit is contained in:
@@ -177,6 +177,11 @@ kinect:
|
||||
# camera cache; it only gates browser-requested broadcasts.
|
||||
captureCooldownMs: 10000
|
||||
|
||||
balanceBoard:
|
||||
# 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
|
||||
|
||||
buttonBox:
|
||||
enabled: false
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ require('./src/services/buttonBoxService');
|
||||
require('./src/services/barcodeScannerService');
|
||||
require('./src/services/barcodeGameService');
|
||||
require('./src/services/kinectService');
|
||||
require('./src/services/balanceBoardService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/batteryManager');
|
||||
require('./src/services/replayEngineV2');
|
||||
|
||||
@@ -16,6 +16,8 @@ MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
|
||||
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
|
||||
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This installer must be run with sudo/root." >&2
|
||||
@@ -30,6 +32,8 @@ fi
|
||||
TARGET_USER="$SUDO_USER"
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SERVER_DIR="$SCRIPT_DIR"
|
||||
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
|
||||
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
|
||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||
@@ -134,7 +138,11 @@ dnf install -y \
|
||||
gstreamer1-rtsp-server \
|
||||
libfreenect \
|
||||
libfreenect-devel \
|
||||
libusb1-devel >/dev/null
|
||||
libusb1-devel \
|
||||
bluez \
|
||||
wiiuse \
|
||||
wiiuse-devel \
|
||||
libcap >/dev/null
|
||||
NODE_BIN="$(command -v node)"
|
||||
|
||||
echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE"
|
||||
@@ -166,12 +174,43 @@ if [[ -f "$SERVER_DIR/src/services/kinectService/native/Makefile" ]]; then
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make"
|
||||
fi
|
||||
|
||||
if [[ -f "$BALANCE_BOARD_NATIVE_DIR/Makefile" ]]; then
|
||||
echo " Building native Balance Board bridge..."
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$BALANCE_BOARD_NATIVE_DIR' && make"
|
||||
if [[ ! -x "$BALANCE_BOARD_WORKER" ]]; then
|
||||
echo "Balance Board worker build did not create $BALANCE_BOARD_WORKER" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Only this small audited bridge needs the management socket used for the
|
||||
# board's raw six-byte pairing PIN and the two reserved HID PSMs used by
|
||||
# front-button reconnects. Never grant either capability to node or the full
|
||||
# multirover service executable.
|
||||
setcap cap_net_admin,cap_net_bind_service+ep "$BALANCE_BOARD_WORKER"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||
fi
|
||||
|
||||
# Bluetoothd remains responsible for discovery and the one-time bond, but its
|
||||
# generic input plugin otherwise reserves control PSM 0x11 and interrupt PSM
|
||||
# 0x13 before the Balance Board worker can listen for the board's front-button
|
||||
# reconnect. This dedicated rover server gives those two HID listeners to the
|
||||
# worker; every other BlueZ profile is left enabled. Clearing ExecStart is
|
||||
# required by systemd before replacing the vendor unit's command in a drop-in.
|
||||
install -d -m 0755 "$BLUETOOTH_OVERRIDE_DIR"
|
||||
cat > "$BLUETOOTH_OVERRIDE" <<'EOF'
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/libexec/bluetooth/bluetoothd --noplugin=input
|
||||
EOF
|
||||
chmod 0644 "$BLUETOOTH_OVERRIDE"
|
||||
systemctl daemon-reload
|
||||
systemctl enable bluetooth.service
|
||||
systemctl restart bluetooth.service
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
@@ -266,8 +305,8 @@ EOF
|
||||
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=Multi-Roomba Rover control server
|
||||
After=network-online.target mediamtx.service
|
||||
Wants=network-online.target
|
||||
After=network-online.target mediamtx.service bluetooth.service
|
||||
Wants=network-online.target bluetooth.service
|
||||
|
||||
[Service]
|
||||
User=$TARGET_USER
|
||||
@@ -304,3 +343,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."
|
||||
echo "Wii Balance Board direct Bluetooth bridge and front-button listener 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
File diff suppressed because one or more lines are too long
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/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-C0TiWXHh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CSRq98ER.css">
|
||||
<script type="module" crossorigin src="/assets/index-Da9ufxPv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BcBTKEa5.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -46,6 +46,7 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
const kinectConfig = config.kinect || {};
|
||||
const buttonBoxConfig = config.buttonBox || {};
|
||||
const barcodeScannerConfig = config.barcodeScanner || {};
|
||||
const balanceBoardConfig = config.balanceBoard || {};
|
||||
const barcodeGamesConfig = config.barcodeGames || {};
|
||||
const socialsConfig = config.socials || {};
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
@@ -68,6 +69,10 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
kinect: asBoolean(kinectConfig.enabled),
|
||||
buttonBox: asBoolean(buttonBoxConfig.enabled),
|
||||
barcodeScanner,
|
||||
// The worker performs its own runtime availability reporting. Advertising
|
||||
// the feature from the explicit config switch lets the UI show useful
|
||||
// commissioning and hardware-error states even before a board is paired.
|
||||
balanceBoard: asBoolean(balanceBoardConfig.enabled),
|
||||
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
|
||||
lift: Boolean(
|
||||
homeAssistant &&
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// 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, shutdown, and protocol validation; scale policy remains in index.js.
|
||||
const { spawn } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const path = require('path');
|
||||
|
||||
const WORKER_PATH =
|
||||
process.env.BALANCE_BOARD_WORKER ||
|
||||
path.join(__dirname, 'native', 'balance_board_worker');
|
||||
const RESTART_DELAY_MS = 2000;
|
||||
const STDERR_LOG_INTERVAL_MS = 5000;
|
||||
|
||||
function createBalanceBoardHardware({ logger, address = '', simulate = false } = {}) {
|
||||
const events = new EventEmitter();
|
||||
let worker = null;
|
||||
let stdoutBuffer = '';
|
||||
let stopped = false;
|
||||
let restarting = false;
|
||||
let restartTimer = null;
|
||||
let lastStderrLogAt = 0;
|
||||
let suppressedStderrLines = 0;
|
||||
let currentAddress = address;
|
||||
|
||||
function emitProtocolError(message) {
|
||||
events.emit('message', {
|
||||
type: 'status',
|
||||
state: 'error',
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
function processStdout(chunk) {
|
||||
stdoutBuffer += chunk.toString('utf8');
|
||||
let newline = stdoutBuffer.indexOf('\n');
|
||||
while (newline !== -1) {
|
||||
const line = stdoutBuffer.slice(0, newline).trim();
|
||||
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
||||
if (line) {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||
throw new Error('message needs a type');
|
||||
}
|
||||
events.emit('message', message);
|
||||
} catch (err) {
|
||||
// A corrupted stdout line means measurement framing can no longer be
|
||||
// trusted. Surface the exact line rather than silently discarding a
|
||||
// potential hardware failure that would otherwise look like zero kg.
|
||||
emitProtocolError(`balance board worker returned invalid JSON: ${err.message}`);
|
||||
logger?.warn?.('Balance Board worker protocol error', { line, error: err.message });
|
||||
}
|
||||
}
|
||||
newline = stdoutBuffer.indexOf('\n');
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRestart() {
|
||||
if (stopped || restartTimer) return;
|
||||
restartTimer = setTimeout(() => {
|
||||
restartTimer = null;
|
||||
start();
|
||||
}, RESTART_DELAY_MS);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (stopped || (worker && !worker.killed)) return;
|
||||
stdoutBuffer = '';
|
||||
|
||||
const child = spawn(WORKER_PATH, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
BALANCE_BOARD_ADDRESS: currentAddress || '',
|
||||
BALANCE_BOARD_SIMULATE: simulate ? 'cycle' : '',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
worker = child;
|
||||
|
||||
child.stdout.on('data', processStdout);
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const text = chunk.toString('utf8').trim();
|
||||
if (!text) return;
|
||||
const now = Date.now();
|
||||
if (now - lastStderrLogAt >= STDERR_LOG_INTERVAL_MS) {
|
||||
const suffix = suppressedStderrLines
|
||||
? ` (${suppressedStderrLines} worker stderr lines suppressed)`
|
||||
: '';
|
||||
logger?.warn?.(`Balance Board worker: ${text}${suffix}`);
|
||||
lastStderrLogAt = now;
|
||||
suppressedStderrLines = 0;
|
||||
} else {
|
||||
suppressedStderrLines += 1;
|
||||
}
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
if (worker === child) worker = null;
|
||||
emitProtocolError(`balance board worker failed to start: ${err.message}`);
|
||||
scheduleRestart();
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
if (worker === child) worker = null;
|
||||
if (!stopped) {
|
||||
// Admin unpair deliberately replaces the worker with an empty address.
|
||||
// Do not turn that expected exit into a red hardware-error state while
|
||||
// still using the normal restart scheduler for the replacement.
|
||||
if (!restarting) emitProtocolError(`balance board worker exited (${signal || code})`);
|
||||
restarting = false;
|
||||
scheduleRestart();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopped = true;
|
||||
restarting = false;
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer);
|
||||
restartTimer = null;
|
||||
}
|
||||
if (!worker) return;
|
||||
const child = worker;
|
||||
worker = null;
|
||||
try {
|
||||
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
|
||||
} catch (_err) {
|
||||
// The worker may have already closed stdin while its exit event is still
|
||||
// queued. SIGTERM below remains the reliable cleanup path.
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
// bluetoothctl may still be finishing a bounded pairing command inside a
|
||||
// worker thread. Do not let that delay server shutdown indefinitely.
|
||||
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
|
||||
}, 1500).unref();
|
||||
}
|
||||
|
||||
function restart() {
|
||||
if (stopped) return;
|
||||
if (!worker) {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
|
||||
const child = worker;
|
||||
restarting = true;
|
||||
try {
|
||||
// An admin forget changes the address used in the child environment. A
|
||||
// controlled restart lets the replacement worker start with that new
|
||||
// value, while the existing close handler remains the single owner of
|
||||
// delayed respawn and avoids overlapping Bluetooth listeners.
|
||||
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
|
||||
} catch (_err) {
|
||||
// The child may have already closed stdin; SIGTERM below still guarantees
|
||||
// that it cannot keep listening for the address that was just forgotten.
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
|
||||
}, 1500).unref();
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
setAddress(nextAddress) {
|
||||
// The factory can be created before first commissioning. Preserve the
|
||||
// newly paired address for later bridge restarts in the same Node process
|
||||
// instead of reverting the replacement worker to discovery mode.
|
||||
currentAddress = typeof nextAddress === 'string' ? nextAddress.trim().toUpperCase() : '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBalanceBoardHardware,
|
||||
};
|
||||
@@ -0,0 +1,575 @@
|
||||
// Balance Board Service
|
||||
// Purpose: Exposes one Wii Balance Board as a self-pairing Bluetooth scale.
|
||||
// Scope: Stores pairing and admin zero calibration, then publishes status plus live four-corner weight.
|
||||
const fs = require('fs');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
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 { sendAlert } = require('../alertService');
|
||||
const { createBalanceBoardHardware } = require('./hardware');
|
||||
|
||||
const events = new EventEmitter();
|
||||
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 CORNER_KEYS = ['topRight', 'bottomRight', 'topLeft', 'bottomLeft'];
|
||||
const ZERO_SAMPLE_COUNT = 10;
|
||||
const ZERO_SAMPLE_INTERVAL_MS = 1000;
|
||||
const ZERO_MAX_SAMPLE_AGE_MS = 1500;
|
||||
const ZERO_MAX_COMBINED_RANGE_KG = 0.5;
|
||||
const RECORD_PERSIST_DELAY_MS = 1000;
|
||||
const execFileAsync = promisify(execFile);
|
||||
const ALERT_COLOR = '#38bdf8';
|
||||
|
||||
function emptyZeroCorners() {
|
||||
return Object.fromEntries(CORNER_KEYS.map((key) => [key, 0]));
|
||||
}
|
||||
|
||||
function normalizeStoredCorners(value) {
|
||||
if (!value || typeof value !== 'object') return emptyZeroCorners();
|
||||
return Object.fromEntries(CORNER_KEYS.map((key) => {
|
||||
const number = Number(value[key]);
|
||||
return [key, Number.isFinite(number) ? Math.max(0, number) : 0];
|
||||
}));
|
||||
}
|
||||
|
||||
function emptyStore() {
|
||||
return {
|
||||
address: '',
|
||||
zeroCorners: emptyZeroCorners(),
|
||||
zeroedAt: null,
|
||||
recordKg: 0,
|
||||
recordedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
|
||||
const zeroedAt = Number.isFinite(Number(parsed?.zeroedAt)) ? Number(parsed.zeroedAt) : null;
|
||||
const recordKg = Number.isFinite(Number(parsed?.recordKg))
|
||||
? roundedWeight(parsed.recordKg)
|
||||
: 0;
|
||||
const recordedAt = Number.isFinite(Number(parsed?.recordedAt))
|
||||
? Number(parsed.recordedAt)
|
||||
: null;
|
||||
return {
|
||||
address,
|
||||
zeroCorners: zeroedAt ? normalizeStoredCorners(parsed.zeroCorners) : emptyZeroCorners(),
|
||||
zeroedAt,
|
||||
recordKg,
|
||||
recordedAt: recordKg > 0 ? recordedAt : null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') logger.warn('Failed to load Balance Board address', err.message);
|
||||
return emptyStore();
|
||||
}
|
||||
}
|
||||
|
||||
function persistStore() {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const temporary = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(temporary, STORE_PATH);
|
||||
}
|
||||
|
||||
function roundedWeight(value) {
|
||||
return Math.round(Math.max(0, Number(value) || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
function cornerWeightsKg(corners = {}) {
|
||||
// Preserve wiiuse's factory-calibrated load cells in kilograms. The separate
|
||||
// admin zero calibration below is an installation baseline layered on top of
|
||||
// this factory conversion; it must never replace the hardware calibration.
|
||||
return {
|
||||
topRight: roundedWeight((Number(corners.topRight) || 0) / 100),
|
||||
bottomRight: roundedWeight((Number(corners.bottomRight) || 0) / 100),
|
||||
topLeft: roundedWeight((Number(corners.topLeft) || 0) / 100),
|
||||
bottomLeft: roundedWeight((Number(corners.bottomLeft) || 0) / 100),
|
||||
};
|
||||
}
|
||||
|
||||
function subtractZero(rawCorners) {
|
||||
const baseline = store.zeroedAt ? store.zeroCorners : emptyZeroCorners();
|
||||
return Object.fromEntries(CORNER_KEYS.map((key) => [
|
||||
key,
|
||||
roundedWeight(Math.max(0, rawCorners[key] - baseline[key])),
|
||||
]));
|
||||
}
|
||||
|
||||
function totalCornerWeight(corners) {
|
||||
return roundedWeight(CORNER_KEYS.reduce((total, key) => total + corners[key], 0));
|
||||
}
|
||||
|
||||
let store = enabled ? loadStore() : emptyStore();
|
||||
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 latestRawCorners = null;
|
||||
let latestRawFrameAt = 0;
|
||||
let zeroTimer = null;
|
||||
let recordPersistTimer = null;
|
||||
let zeroSamples = [];
|
||||
let zeroProgress = {
|
||||
active: false,
|
||||
samplesCollected: 0,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: '',
|
||||
};
|
||||
let previousWorkerState = '';
|
||||
let lastAlertKey = '';
|
||||
let unpairing = false;
|
||||
|
||||
function sendRawAlert(state, message = '') {
|
||||
const rawMessage = message ? `${state}: ${message}` : state;
|
||||
if (rawMessage === lastAlertKey) return;
|
||||
lastAlertKey = rawMessage;
|
||||
sendAlert({ color: ALERT_COLOR, title: 'Balance Board', message: rawMessage });
|
||||
}
|
||||
|
||||
function sendStatusAlert(workerState, message = '') {
|
||||
const shouldAlert =
|
||||
workerState === 'connected' ||
|
||||
workerState === 'sleeping' ||
|
||||
workerState === 'connection-failed' ||
|
||||
workerState === 'error' ||
|
||||
(workerState === 'waiting' && previousWorkerState === 'connected');
|
||||
|
||||
previousWorkerState = workerState;
|
||||
if (!shouldAlert) return;
|
||||
|
||||
// Keep the alert at the same system-level boundary as the worker protocol:
|
||||
// state first, followed by its exact detail when one exists. The service does
|
||||
// not reinterpret failures as friendlier product copy, but still collapses
|
||||
// identical retries so a failing reconnect cannot flood the activity feed.
|
||||
sendRawAlert(workerState, message);
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
enabled,
|
||||
paired: Boolean(store.address) || Boolean(rawConfig.simulate),
|
||||
address: store.address || (rawConfig.simulate ? 'SIMULATED' : null),
|
||||
connected,
|
||||
status,
|
||||
detail,
|
||||
batteryPercent,
|
||||
recordKg: store.recordKg,
|
||||
recordedAt: store.recordedAt,
|
||||
calibration: {
|
||||
calibrated: Boolean(store.zeroedAt),
|
||||
zeroedAt: store.zeroedAt,
|
||||
...zeroProgress,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function clearRecordPersistTimer() {
|
||||
if (!recordPersistTimer) return;
|
||||
clearTimeout(recordPersistTimer);
|
||||
recordPersistTimer = null;
|
||||
}
|
||||
|
||||
function scheduleRecordPersistence() {
|
||||
clearRecordPersistTimer();
|
||||
|
||||
// A person driving onto the board produces many successively larger frames.
|
||||
// Waiting until the maximum has stopped changing prevents a synchronous JSON
|
||||
// rewrite for every 20 Hz sensor frame while still saving a settled record
|
||||
// promptly enough to survive an ordinary service restart.
|
||||
recordPersistTimer = setTimeout(() => {
|
||||
recordPersistTimer = null;
|
||||
persistStore();
|
||||
}, RECORD_PERSIST_DELAY_MS);
|
||||
recordPersistTimer.unref?.();
|
||||
}
|
||||
|
||||
function publishLatestFrame() {
|
||||
if (!latestFrame) return;
|
||||
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
|
||||
}
|
||||
|
||||
function resetWeightRecord() {
|
||||
clearRecordPersistTimer();
|
||||
|
||||
// Reset means "start measuring the record from now." If the board currently
|
||||
// has a load, that current measurement is the first candidate in the new
|
||||
// period. Saving it immediately avoids briefly showing zero before the next
|
||||
// live frame restores the same weight as the record.
|
||||
const currentWeight = connected && latestFrame ? roundedWeight(latestFrame.totalKg) : 0;
|
||||
store.recordKg = currentWeight;
|
||||
store.recordedAt = currentWeight > 0 ? Date.now() : null;
|
||||
persistStore();
|
||||
|
||||
if (latestFrame) {
|
||||
latestFrame = {
|
||||
...latestFrame,
|
||||
recordKg: store.recordKg,
|
||||
recordedAt: store.recordedAt,
|
||||
};
|
||||
publishLatestFrame();
|
||||
}
|
||||
events.emit('change', { state: getState() });
|
||||
sendRawAlert('record-reset');
|
||||
}
|
||||
|
||||
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 publishCalibrationState() {
|
||||
// Calibration progress belongs in the ordinary session payload because it
|
||||
// changes only once per second for ten seconds. Live 20 Hz weights remain in
|
||||
// their dedicated room and never trigger a full-session broadcast.
|
||||
events.emit('change', { state: getState() });
|
||||
}
|
||||
|
||||
function clearZeroTimer() {
|
||||
if (!zeroTimer) return;
|
||||
clearInterval(zeroTimer);
|
||||
zeroTimer = null;
|
||||
}
|
||||
|
||||
function failZeroCalibration(error, { alert = true } = {}) {
|
||||
clearZeroTimer();
|
||||
zeroSamples = [];
|
||||
zeroProgress = {
|
||||
active: false,
|
||||
samplesCollected: 0,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: String(error || 'Calibration failed'),
|
||||
};
|
||||
publishCalibrationState();
|
||||
if (alert) sendRawAlert('zero-failed', zeroProgress.error);
|
||||
}
|
||||
|
||||
function finishZeroCalibration() {
|
||||
clearZeroTimer();
|
||||
|
||||
// A single average could hide movement that returns to its starting point.
|
||||
// Sum every corner's complete ten-second range before accepting the result so
|
||||
// distributed movement cannot hide below four independent thresholds. Retain
|
||||
// three decimals so averaging ten centi-kilogram samples does not throw away
|
||||
// useful sub-centi-kilogram precision in the persisted baseline.
|
||||
const combinedRange = CORNER_KEYS.reduce((totalRange, key) => {
|
||||
const values = zeroSamples.map((sample) => sample[key]);
|
||||
return totalRange + Math.max(...values) - Math.min(...values);
|
||||
}, 0);
|
||||
if (combinedRange > ZERO_MAX_COMBINED_RANGE_KG) {
|
||||
failZeroCalibration('Load moved during the ten-second calibration.');
|
||||
return;
|
||||
}
|
||||
|
||||
store.zeroCorners = Object.fromEntries(CORNER_KEYS.map((key) => {
|
||||
const average = zeroSamples.reduce((sum, sample) => sum + sample[key], 0) /
|
||||
zeroSamples.length;
|
||||
return [key, Math.round(average * 1000) / 1000];
|
||||
}));
|
||||
store.zeroedAt = Date.now();
|
||||
// A new zero changes the meaning of every adjusted weight, so an old record
|
||||
// cannot be compared with measurements under the new baseline.
|
||||
clearRecordPersistTimer();
|
||||
store.recordKg = 0;
|
||||
store.recordedAt = null;
|
||||
persistStore();
|
||||
zeroSamples = [];
|
||||
zeroProgress = {
|
||||
active: false,
|
||||
samplesCollected: ZERO_SAMPLE_COUNT,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: '',
|
||||
};
|
||||
publishCalibrationState();
|
||||
sendRawAlert('zeroed');
|
||||
}
|
||||
|
||||
function takeZeroSample() {
|
||||
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
|
||||
failZeroCalibration('Live Balance Board data stopped during calibration.');
|
||||
return;
|
||||
}
|
||||
|
||||
zeroSamples.push({ ...latestRawCorners });
|
||||
zeroProgress = {
|
||||
active: true,
|
||||
samplesCollected: zeroSamples.length,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: '',
|
||||
};
|
||||
publishCalibrationState();
|
||||
if (zeroSamples.length >= ZERO_SAMPLE_COUNT) finishZeroCalibration();
|
||||
}
|
||||
|
||||
function startZeroCalibration() {
|
||||
if (zeroProgress.active) throw new Error('Balance Board zero calibration is already running');
|
||||
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
|
||||
throw new Error('The Balance Board must be connected and sending weight data');
|
||||
}
|
||||
|
||||
zeroSamples = [];
|
||||
zeroProgress = {
|
||||
active: true,
|
||||
samplesCollected: 0,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: '',
|
||||
};
|
||||
publishCalibrationState();
|
||||
sendRawAlert('zeroing');
|
||||
// Delaying the first sample by one interval makes this a real ten-second
|
||||
// calibration rather than ten rapid reads followed by nine seconds of UI.
|
||||
zeroTimer = setInterval(takeZeroSample, ZERO_SAMPLE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function processFrame(message = {}) {
|
||||
const rawCorners = cornerWeightsKg(message.corners);
|
||||
latestRawCorners = rawCorners;
|
||||
latestRawFrameAt = Date.now();
|
||||
if (Number.isFinite(Number(message.batteryPercent))) {
|
||||
batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent)));
|
||||
}
|
||||
|
||||
connected = true;
|
||||
updateStatus('connected', 'Live weight is updating.');
|
||||
const adjustedCorners = subtractZero(rawCorners);
|
||||
const totalKg = totalCornerWeight(adjustedCorners);
|
||||
if (totalKg > store.recordKg) {
|
||||
// Store only adjusted weight so the displayed record uses the same admin
|
||||
// zero baseline as the live total and all four corner readings.
|
||||
store.recordKg = totalKg;
|
||||
store.recordedAt = Date.now();
|
||||
scheduleRecordPersistence();
|
||||
}
|
||||
latestFrame = {
|
||||
totalKg,
|
||||
corners: adjustedCorners,
|
||||
batteryPercent,
|
||||
recordKg: store.recordKg,
|
||||
recordedAt: store.recordedAt,
|
||||
};
|
||||
publishLatestFrame();
|
||||
}
|
||||
|
||||
function handleWorkerMessage(message = {}) {
|
||||
if (message.type === 'frame') {
|
||||
processFrame(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'paired') {
|
||||
const address = typeof message.address === 'string' ? message.address.trim().toUpperCase() : '';
|
||||
if (address && address !== store.address) {
|
||||
store.address = address;
|
||||
// A zero baseline belongs to one physical board and whatever permanent
|
||||
// platform/load was present when an admin calibrated it. Never carry that
|
||||
// baseline across commissioning a different Bluetooth identity.
|
||||
store.zeroCorners = emptyZeroCorners();
|
||||
store.zeroedAt = null;
|
||||
clearRecordPersistTimer();
|
||||
store.recordKg = 0;
|
||||
store.recordedAt = null;
|
||||
persistStore();
|
||||
}
|
||||
hardware?.setAddress(address);
|
||||
sendRawAlert('paired');
|
||||
updateStatus('connecting', 'Paired. Connecting to the board now.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== 'status') return;
|
||||
const workerState = String(message.state || 'unknown');
|
||||
sendStatusAlert(workerState, message.error || '');
|
||||
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;
|
||||
updateStatus('connected', 'Connected. Waiting for live weight data.');
|
||||
} else if (workerState === 'link-detected') {
|
||||
connected = false;
|
||||
// The native bridge can now distinguish which half of the board's HID
|
||||
// connection reached the server. Preserve that diagnostic until both
|
||||
// channels arrive; the generic text remains for the outbound Sync flow.
|
||||
updateStatus('connecting', message.error || 'Board responded. Reading its sensor calibration.');
|
||||
} else if (workerState === 'connection-failed') {
|
||||
connected = false;
|
||||
latestFrame = null;
|
||||
latestRawCorners = null;
|
||||
latestRawFrameAt = 0;
|
||||
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
|
||||
updateStatus('connection-failed', message.error || 'The direct Balance Board connection failed.');
|
||||
} else if (workerState === 'sleeping') {
|
||||
connected = false;
|
||||
latestFrame = null;
|
||||
latestRawCorners = null;
|
||||
latestRawFrameAt = 0;
|
||||
if (zeroProgress.active) failZeroCalibration('Board slept during calibration.');
|
||||
updateStatus('sleeping', message.error || 'Board is asleep. Press the front power button to wake it.');
|
||||
} else if (workerState === 'waiting') {
|
||||
connected = false;
|
||||
latestFrame = null;
|
||||
latestRawCorners = null;
|
||||
latestRawFrameAt = 0;
|
||||
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
|
||||
updateStatus('waiting', message.error || 'Press the front power button. The server will keep trying to connect.');
|
||||
} else if (workerState === 'error') {
|
||||
connected = false;
|
||||
latestRawCorners = null;
|
||||
latestRawFrameAt = 0;
|
||||
if (zeroProgress.active) failZeroCalibration('Worker stopped during calibration.');
|
||||
updateStatus('error', message.error || 'The Balance Board worker stopped.');
|
||||
}
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('balanceBoard:subscribe', (_payload = {}, cb = () => {}) => {
|
||||
socket.join(FRAME_ROOM);
|
||||
if (latestFrame) socket.emit('balanceBoard:frame', latestFrame);
|
||||
cb({ success: true });
|
||||
});
|
||||
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
|
||||
socket.on('balanceBoard:zero', (_payload = {}, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
startZeroCalibration();
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
cb({ error: err.message || 'Failed to start Balance Board zero calibration' });
|
||||
}
|
||||
});
|
||||
socket.on('balanceBoard:resetRecord', (_payload = {}, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resetWeightRecord();
|
||||
cb({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Failed to reset Balance Board weight record', err);
|
||||
cb({ error: err.message || 'Failed to reset the Balance Board weight record' });
|
||||
}
|
||||
});
|
||||
socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
if (unpairing) {
|
||||
cb({ error: 'The Balance Board is already being unpaired' });
|
||||
return;
|
||||
}
|
||||
|
||||
unpairing = true;
|
||||
const address = store.address;
|
||||
let bluetoothWarning = '';
|
||||
try {
|
||||
if (address) {
|
||||
try {
|
||||
// A complete forget removes both sources of remembered identity. If
|
||||
// only the JSON address or only the BlueZ bond were removed, the next
|
||||
// red-Sync attempt could inherit half of the previous relationship.
|
||||
await execFileAsync('bluetoothctl', ['remove', address], { timeout: 10000 });
|
||||
} catch (err) {
|
||||
bluetoothWarning = String(
|
||||
err?.stderr || err?.message || 'BlueZ did not remove the bond',
|
||||
).trim();
|
||||
logger.warn('Balance Board BlueZ bond removal failed', bluetoothWarning);
|
||||
}
|
||||
}
|
||||
|
||||
store.address = '';
|
||||
store.zeroCorners = emptyZeroCorners();
|
||||
store.zeroedAt = null;
|
||||
clearRecordPersistTimer();
|
||||
store.recordKg = 0;
|
||||
store.recordedAt = null;
|
||||
persistStore();
|
||||
clearZeroTimer();
|
||||
zeroSamples = [];
|
||||
zeroProgress = {
|
||||
active: false,
|
||||
samplesCollected: 0,
|
||||
totalSamples: ZERO_SAMPLE_COUNT,
|
||||
error: '',
|
||||
};
|
||||
connected = false;
|
||||
batteryPercent = null;
|
||||
latestFrame = null;
|
||||
latestRawCorners = null;
|
||||
latestRawFrameAt = 0;
|
||||
previousWorkerState = '';
|
||||
hardware?.setAddress('');
|
||||
hardware?.restart();
|
||||
updateStatus('starting', 'Starting Bluetooth discovery.');
|
||||
sendRawAlert('unpaired');
|
||||
cb({ success: true, warning: bluetoothWarning || null });
|
||||
} catch (err) {
|
||||
logger.error('Failed to unpair Balance Board', err);
|
||||
cb({ error: err.message || 'Failed to unpair the Balance Board' });
|
||||
} finally {
|
||||
unpairing = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (enabled) {
|
||||
hardware = createBalanceBoardHardware({
|
||||
logger,
|
||||
address: store.address,
|
||||
simulate: Boolean(rawConfig.simulate || process.env.BALANCE_BOARD_SIMULATE),
|
||||
});
|
||||
hardware.events.on('message', handleWorkerMessage);
|
||||
hardware.start();
|
||||
} else {
|
||||
logger.info('Balance Board disabled by config');
|
||||
}
|
||||
|
||||
function installShutdownHooks() {
|
||||
const shutdown = () => {
|
||||
clearZeroTimer();
|
||||
// A record may still be inside the short debounce window when the process
|
||||
// receives a normal shutdown signal. Flush that newest maximum before the
|
||||
// hardware worker stops so a clean restart cannot lose it.
|
||||
if (recordPersistTimer) {
|
||||
clearRecordPersistTimer();
|
||||
persistStore();
|
||||
}
|
||||
hardware?.stop();
|
||||
};
|
||||
process.once('exit', shutdown);
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
installShutdownHooks();
|
||||
|
||||
module.exports = {
|
||||
getState,
|
||||
balanceBoardEvents: events,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
CXX ?= g++
|
||||
|
||||
# Wiiuse owns the Balance Board's HID control/interrupt channels and applies the
|
||||
# calibration stored in the board. This deliberately avoids BlueZ's generic HID
|
||||
# profile: current BlueZ requests medium link security for a bonded board, and
|
||||
# the original Balance Board rejects that negotiation before an input device is
|
||||
# created.
|
||||
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
|
||||
LDLIBS += -lwiiuse -lbluetooth -pthread
|
||||
|
||||
TARGET := balance_board_worker
|
||||
SRC := balance_board_worker.cpp
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(SRC)
|
||||
$(CXX) $(CXXFLAGS) -o $@ $< $(LDLIBS)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,10 @@ const { getState: getHomeAssistantState, homeAssistantEvents } = require('../hom
|
||||
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
|
||||
const { getState: getLiftState, liftEvents } = require('../liftService');
|
||||
const { getState: getKinectState, kinectEvents } = require('../kinectService');
|
||||
const {
|
||||
getState: getBalanceBoardState,
|
||||
balanceBoardEvents,
|
||||
} = require('../balanceBoardService');
|
||||
const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService');
|
||||
const { getNickname, nicknameEvents } = require('../nicknameService');
|
||||
const {
|
||||
@@ -197,6 +201,7 @@ function buildSession(socket) {
|
||||
neato: getNeatoState(),
|
||||
lift: getLiftState(),
|
||||
kinect: getKinectState(),
|
||||
balanceBoard: getBalanceBoardState(),
|
||||
replay: getReplayState(),
|
||||
replaySources: getReplaySources(socket),
|
||||
health: getHealthSnapshot(),
|
||||
@@ -397,6 +402,14 @@ kinectEvents.on('change', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
balanceBoardEvents.on('change', () => {
|
||||
// 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();
|
||||
});
|
||||
|
||||
replayEvents.on('update', () => {
|
||||
logger.info('Replay cooldown updated; syncing all clients');
|
||||
syncAll();
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './controls/index.js';
|
||||
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
|
||||
import KinectPanel from './components/KinectPanel/index.jsx';
|
||||
import BalanceBoardPanel from './components/BalanceBoardPanel/index.jsx';
|
||||
import DriverVideo from './components/DriverVideo/index.jsx';
|
||||
import RightPaneTabs from './components/RightPaneTabs/index.jsx';
|
||||
import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
|
||||
@@ -171,6 +172,7 @@ function MobileFeatureTabs({
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<NeatoCard />
|
||||
<LiftCard />
|
||||
<BalanceBoardPanel />
|
||||
<BarcodeGamesPanel />
|
||||
<OdometerPanel />
|
||||
<ButtonBoxPanel />
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// Balance Board Panel
|
||||
// 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_CORNERS = {
|
||||
topLeft: 0,
|
||||
topRight: 0,
|
||||
bottomLeft: 0,
|
||||
bottomRight: 0,
|
||||
};
|
||||
const EMPTY_FRAME = {
|
||||
totalKg: 0,
|
||||
batteryPercent: null,
|
||||
// Null distinguishes "no live frame received yet" from a legitimate record
|
||||
// of zero, allowing the persisted session value to remain visible while the
|
||||
// socket room subscription is being established.
|
||||
recordKg: null,
|
||||
recordedAt: null,
|
||||
corners: EMPTY_CORNERS,
|
||||
};
|
||||
|
||||
function formatWeight(value) {
|
||||
const weight = Number(value);
|
||||
return Number.isFinite(weight) ? `${weight.toFixed(2)} kg` : '0.00 kg';
|
||||
}
|
||||
|
||||
function finiteNumber(value) {
|
||||
if (value == null || value === '') return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function centerOfPressure(corners) {
|
||||
const topLeft = Math.max(0, finiteNumber(corners.topLeft) || 0);
|
||||
const topRight = Math.max(0, finiteNumber(corners.topRight) || 0);
|
||||
const bottomLeft = Math.max(0, finiteNumber(corners.bottomLeft) || 0);
|
||||
const bottomRight = Math.max(0, finiteNumber(corners.bottomRight) || 0);
|
||||
const total = topLeft + topRight + bottomLeft + bottomRight;
|
||||
|
||||
// Only an exact zero stays centered because dividing by zero cannot produce a
|
||||
// position. Every positive reading participates immediately, with no minimum
|
||||
// weight or center deadzone hiding small shifts reported by the load cells.
|
||||
if (total === 0) return { left: 50, top: 50, active: false };
|
||||
const horizontal = ((topRight + bottomRight) - (topLeft + bottomLeft)) / total;
|
||||
const vertical = ((bottomLeft + bottomRight) - (topLeft + topRight)) / total;
|
||||
return {
|
||||
left: 50 + Math.max(-1, Math.min(1, horizontal)) * 37,
|
||||
top: 50 + Math.max(-1, Math.min(1, vertical)) * 37,
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
function CornerReading({ className, label, value }) {
|
||||
return (
|
||||
<div className={`surface absolute min-w-[5.5rem] text-center ${className}`}>
|
||||
<div className="text-[0.62rem] text-slate-400">{label}</div>
|
||||
<div className="text-sm font-semibold text-slate-100">{formatWeight(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BalanceBoardPanel() {
|
||||
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.
|
||||
if (!enabled) return null;
|
||||
return <BalanceBoardPanelContent />;
|
||||
}
|
||||
|
||||
function BalanceBoardPanelContent() {
|
||||
const socket = useSocket();
|
||||
const board = useSessionSelector((state) => state.session?.balanceBoard || null);
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const [frame, setFrame] = useState(EMPTY_FRAME);
|
||||
const [unpairing, setUnpairing] = useState(false);
|
||||
const [zeroRequesting, setZeroRequesting] = useState(false);
|
||||
const [resettingRecord, setResettingRecord] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return undefined;
|
||||
const handleFrame = (next = {}) => setFrame({ ...EMPTY_FRAME, ...next });
|
||||
|
||||
// Socket.IO room membership belongs to one server-side connection, not to
|
||||
// the long-lived browser socket object. A brief network interruption gives
|
||||
// the browser a new server-side socket while React keeps this component and
|
||||
// this effect mounted, so subscribing only here would silently lose all
|
||||
// later weight frames. Rejoin after every connection as well as immediately
|
||||
// for the already-connected case.
|
||||
const subscribe = () => {
|
||||
socket.emit('balanceBoard:subscribe', {}, () => {});
|
||||
};
|
||||
|
||||
socket.on('balanceBoard:frame', handleFrame);
|
||||
socket.on('connect', subscribe);
|
||||
subscribe();
|
||||
|
||||
return () => {
|
||||
socket.off('balanceBoard:frame', handleFrame);
|
||||
socket.off('connect', subscribe);
|
||||
// The panel is the only consumer represented by this component. Leaving
|
||||
// the room on unmount prevents an inactive route or tab from continuing
|
||||
// to receive the board's continuous measurement stream.
|
||||
socket.emit('balanceBoard:unsubscribe');
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
// 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 corners = { ...EMPTY_CORNERS, ...(liveFrame.corners || {}) };
|
||||
const center = centerOfPressure(corners);
|
||||
const liveBattery = finiteNumber(liveFrame.batteryPercent);
|
||||
const sessionBattery = finiteNumber(board?.batteryPercent);
|
||||
const battery = liveBattery ?? sessionBattery;
|
||||
// Live frames make a newly reached record move immediately. The session copy
|
||||
// remains available while the board sleeps or before this panel subscribes,
|
||||
// which is important because the record belongs to the installation rather
|
||||
// than to one Bluetooth connection.
|
||||
const liveRecord = board?.connected ? finiteNumber(frame.recordKg) : null;
|
||||
const sessionRecord = finiteNumber(board?.recordKg);
|
||||
const record = liveRecord ?? sessionRecord ?? 0;
|
||||
const sleeping = board?.status === 'sleeping';
|
||||
const isAdmin = role === 'admin' || role === 'lockdown';
|
||||
const calibration = board?.calibration || null;
|
||||
const zeroing = Boolean(calibration?.active);
|
||||
|
||||
const zero = () => {
|
||||
if (zeroRequesting || zeroing || !board?.connected) return;
|
||||
if (!window.confirm('Use the board’s current load as zero? Keep everything still for ten seconds.')) return;
|
||||
setZeroRequesting(true);
|
||||
socket.emit('balanceBoard:zero', {}, (response = {}) => {
|
||||
setZeroRequesting(false);
|
||||
if (response.error) window.alert(response.error);
|
||||
});
|
||||
};
|
||||
|
||||
const unpair = () => {
|
||||
if (unpairing || !board?.paired) return;
|
||||
if (!window.confirm('Unpair this Balance Board and require the red Sync button to pair it again?')) return;
|
||||
setUnpairing(true);
|
||||
socket.emit('balanceBoard:unpair', {}, (response = {}) => {
|
||||
setUnpairing(false);
|
||||
if (response.error) {
|
||||
window.alert(response.error);
|
||||
} else if (response.warning) {
|
||||
window.alert('Board forgotten locally, but BlueZ reported a bond-removal warning.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetRecord = () => {
|
||||
if (resettingRecord) return;
|
||||
if (!window.confirm('Reset the highest weight record?')) return;
|
||||
setResettingRecord(true);
|
||||
socket.emit('balanceBoard:resetRecord', {}, (response = {}) => {
|
||||
setResettingRecord(false);
|
||||
if (response.error) window.alert(response.error);
|
||||
});
|
||||
};
|
||||
|
||||
const actions = isAdmin ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-xs disabled:opacity-50"
|
||||
disabled={!board?.connected || zeroRequesting || zeroing || unpairing}
|
||||
onClick={zero}
|
||||
>
|
||||
{zeroing
|
||||
? `Zeroing ${calibration.samplesCollected}/${calibration.totalSamples}`
|
||||
: zeroRequesting ? 'Starting…' : 'Zero'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-xs disabled:opacity-50"
|
||||
disabled={!board?.paired || unpairing || zeroing}
|
||||
onClick={unpair}
|
||||
>
|
||||
{unpairing ? 'Unpairing…' : 'Unpair'}
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Balance Board"
|
||||
className="relative w-full"
|
||||
bodyClassName="text-sm text-slate-200"
|
||||
actions={actions}
|
||||
>
|
||||
{sleeping ? (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-slate-950/85 px-2 text-center">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold text-slate-100">The Balance Board is asleep</p>
|
||||
<p className="text-sm text-slate-300">Press the front power button on the board to wake it.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Keep the measurement column narrow and fixed so the board remains the
|
||||
dominant visual while record and battery stay in one predictable
|
||||
place. Both pieces use the shared dark panel treatment instead of
|
||||
introducing a Balance Board-specific background style. */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_8rem] gap-0.5">
|
||||
<div className="panel-section relative h-52 overflow-hidden">
|
||||
{zeroing ? (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-neutral-950/90 px-2 text-center">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold text-slate-100">
|
||||
Zeroing {calibration.samplesCollected}/{calibration.totalSamples}
|
||||
</p>
|
||||
<p className="text-sm text-slate-300">Keep the board and everything on it still.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<CornerReading className="left-0.5 top-0.5" label="Top left" value={corners.topLeft} />
|
||||
<CornerReading className="right-0.5 top-0.5" label="Top right" value={corners.topRight} />
|
||||
<CornerReading className="bottom-0.5 left-0.5" label="Bottom left" value={corners.bottomLeft} />
|
||||
<CornerReading className="bottom-0.5 right-0.5" label="Bottom right" value={corners.bottomRight} />
|
||||
<div
|
||||
aria-label="Center of pressure"
|
||||
className={`absolute z-10 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border transition-all duration-100 ${
|
||||
center.active
|
||||
? 'border-sky-200 bg-sky-500'
|
||||
: 'border-neutral-500 bg-neutral-600 opacity-50'
|
||||
}`}
|
||||
style={{ left: `${center.left}%`, top: `${center.top}%` }}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="surface px-1 py-0.5 text-center">
|
||||
<div className="text-[0.65rem] text-slate-400">Total weight</div>
|
||||
<div className="text-3xl font-bold leading-none text-white">
|
||||
{formatWeight(liveFrame.totalKg)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid h-52 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
||||
<div className="panel-section flex min-h-0 flex-col items-center justify-center gap-1 text-center">
|
||||
<div className="text-xs text-slate-400">Weight record</div>
|
||||
<div className="text-xl font-bold text-white">{formatWeight(record)}</div>
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-xs disabled:opacity-50"
|
||||
disabled={resettingRecord}
|
||||
onClick={resetRecord}
|
||||
>
|
||||
{resettingRecord ? 'Resetting…' : 'Reset'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="panel-section px-1 py-1 text-center">
|
||||
<div className="text-xs text-slate-400">Battery</div>
|
||||
<div className="text-xl font-semibold text-slate-100">
|
||||
{battery == null ? '—' : `${Math.round(battery)}%`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import RoomCameraPanel from '../RoomCameraPanel/index.jsx';
|
||||
import KinectPanel from '../KinectPanel/index.jsx';
|
||||
import BalanceBoardPanel from '../BalanceBoardPanel/index.jsx';
|
||||
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
||||
import SettingsPanel from '../SettingsPanel/index.jsx';
|
||||
import HelpPanel from '../HelpPanel/index.jsx';
|
||||
@@ -420,6 +421,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<NeatoCard />
|
||||
<LiftCard />
|
||||
<BalanceBoardPanel />
|
||||
<BarcodeGamesPanel />
|
||||
<OdometerPanel />
|
||||
<ButtonBoxPanel />
|
||||
|
||||
Reference in New Issue
Block a user