This commit is contained in:
legop3
2026-07-17 00:51:24 -04:00
parent fe64ec7758
commit 0add90714b
11 changed files with 329 additions and 53 deletions
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
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CIHlBjmn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dpy8jv9j.css">
<script type="module" crossorigin src="/assets/index-ylLtL6Of.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BzceGRD0.css">
</head>
<body>
<div id="root"></div>
@@ -16,6 +16,7 @@ function createBalanceBoardHardware({ logger, address = '', simulate = false } =
let worker = null;
let stdoutBuffer = '';
let stopped = false;
let restarting = false;
let restartTimer = null;
let lastStderrLogAt = 0;
let suppressedStderrLines = 0;
@@ -100,7 +101,11 @@ function createBalanceBoardHardware({ logger, address = '', simulate = false } =
child.on('close', (code, signal) => {
if (worker === child) worker = null;
if (!stopped) {
emitProtocolError(`balance board worker exited (${signal || code})`);
// 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();
}
});
@@ -108,6 +113,7 @@ function createBalanceBoardHardware({ logger, address = '', simulate = false } =
function stop() {
stopped = true;
restarting = false;
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
@@ -129,10 +135,36 @@ function createBalanceBoardHardware({ logger, address = '', simulate = false } =
}, 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
@@ -2,12 +2,16 @@
// Purpose: Exposes one Wii Balance Board as a self-pairing Bluetooth scale.
// Scope: Stores the paired address, applies a small automatic tare, and publishes simple status plus live weight.
const fs = require('fs');
const { 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();
@@ -18,6 +22,14 @@ const STORE_PATH = resolveDataPath('balance-board.json');
const FRAME_ROOM = 'balance-board-viewers';
const TARE_SAMPLE_COUNT = 20;
const MAX_AUTOMATIC_TARE_KG = 2;
const execFileAsync = promisify(execFile);
const ALERT_COLORS = {
connected: '#10b981',
sleeping: '#64748b',
warning: '#f59e0b',
error: '#ef4444',
info: '#38bdf8',
};
function loadStore() {
try {
@@ -49,6 +61,18 @@ function rawWeightKg(corners = {}) {
.reduce((total, key) => total + Math.max(0, Number(corners[key]) || 0), 0) / 100;
}
function cornerWeightsKg(corners = {}) {
// Preserve all four factory-calibrated load cells in kilograms. Their
// relative distribution drives the center-of-pressure dot in the panel,
// while the existing tare continues to apply only to the displayed total.
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),
};
}
let store = enabled ? loadStore() : { address: '' };
let hardware = null;
let status = enabled ? (store.address ? 'waiting' : 'starting') : 'disabled';
@@ -60,6 +84,55 @@ let batteryPercent = null;
let latestFrame = null;
let tareSamples = [];
let tareKg = 0;
let previousWorkerState = '';
let lastAlertKey = '';
let unpairing = false;
function sendStatusAlert(workerState, message = '') {
let alert = null;
if (workerState === 'connected') {
alert = {
color: ALERT_COLORS.connected,
title: 'Balance Board connected',
message: 'Live weight is available.',
};
} else if (workerState === 'sleeping') {
alert = {
color: ALERT_COLORS.sleeping,
title: 'Balance Board sleeping',
message: 'Press the front power button when you are ready to use it.',
};
} else if (workerState === 'waiting' && previousWorkerState === 'connected') {
alert = {
color: ALERT_COLORS.warning,
title: 'Balance Board disconnected',
message: message || 'Press the front power button to reconnect it.',
};
} else if (workerState === 'connection-failed') {
alert = {
color: ALERT_COLORS.error,
title: 'Balance Board connection failed',
message: message || 'The Bluetooth connection did not complete.',
};
} else if (workerState === 'error') {
alert = {
color: ALERT_COLORS.error,
title: 'Balance Board needs attention',
message: message || 'The hardware worker stopped.',
};
}
previousWorkerState = workerState;
if (!alert) return;
// Native reconnect retries may repeat the same result while hardware is out
// of range. Collapse identical status/message pairs so feed alerts remain
// meaningful state changes instead of turning into a transport log.
const key = `${workerState}:${alert.message}`;
if (key === lastAlertKey) return;
lastAlertKey = key;
sendAlert(alert);
}
function getState() {
return {
@@ -103,6 +176,7 @@ function processFrame(message = {}) {
);
latestFrame = {
totalKg: roundedWeight(rawKg - tareKg),
corners: cornerWeightsKg(message.corners),
batteryPercent,
};
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
@@ -121,12 +195,19 @@ function handleWorkerMessage(message = {}) {
persistStore();
}
hardware?.setAddress(address);
lastAlertKey = 'paired';
sendAlert({
color: ALERT_COLORS.info,
title: 'Balance Board paired',
message: 'Bluetooth setup completed.',
});
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') {
@@ -169,6 +250,59 @@ io.on('connection', (socket) => {
cb({ success: true });
});
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
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 = '';
persistStore();
connected = false;
batteryPercent = null;
latestFrame = null;
tareSamples = [];
tareKg = 0;
previousWorkerState = '';
lastAlertKey = 'unpaired';
hardware?.setAddress('');
hardware?.restart();
updateStatus('starting', 'Starting Bluetooth discovery.');
sendAlert({
color: ALERT_COLORS.warning,
title: 'Balance Board unpaired',
message: 'Press the red Sync button underneath the board to pair it again.',
});
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) {
+1 -1
View File
@@ -172,11 +172,11 @@ function MobileFeatureTabs({
<div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard />
<LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel />
<OdometerPanel />
<ButtonBoxPanel />
<KinectPanel />
<BalanceBoardPanel />
</div>
</TabPanel>
<TabPanel id="vip" keepMounted>
+146 -36
View File
@@ -7,25 +7,53 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
import CardFrame from '../CardFrame/index.jsx';
const EMPTY_FRAME = { totalKg: 0, batteryPercent: null };
const EMPTY_CORNERS = {
topLeft: 0,
topRight: 0,
bottomLeft: 0,
bottomRight: 0,
};
const EMPTY_FRAME = { totalKg: 0, batteryPercent: null, corners: EMPTY_CORNERS };
function formatWeight(value) {
const weight = Number(value);
return Number.isFinite(weight) ? `${weight.toFixed(2)} kg` : '0.00 kg';
}
function statusPresentation(value) {
const status = String(value || 'starting');
if (status === 'connected') return { label: 'Connected', tone: 'border-emerald-600 bg-emerald-950 text-emerald-200' };
if (status === 'zeroing') return { label: 'Zeroing', tone: 'border-violet-600 bg-violet-950 text-violet-200' };
if (status === 'pairing') return { label: 'Pairing', tone: 'border-sky-600 bg-sky-950 text-sky-200' };
if (status === 'waiting-for-sync') return { label: 'Waiting for red Sync', tone: 'border-amber-600 bg-amber-950 text-amber-200' };
if (status === 'waiting') return { label: 'Waiting for front button', tone: 'border-amber-600 bg-amber-950 text-amber-200' };
if (status === 'sleeping') return { label: 'Sleeping', tone: 'border-slate-600 bg-slate-900 text-slate-200' };
if (status === 'connecting') return { label: 'Connecting', tone: 'border-sky-600 bg-sky-950 text-sky-200' };
if (status === 'connection-failed') return { label: 'Connection failed', tone: 'border-red-600 bg-red-950 text-red-200' };
if (status === 'error') return { label: 'Error', tone: 'border-red-600 bg-red-950 text-red-200' };
return { label: 'Starting', tone: 'border-slate-600 bg-slate-900 text-slate-200' };
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;
// An empty board naturally has tiny load-cell noise. Keep the marker centered
// and subdued until there is enough weight for its position to mean anything;
// once loaded, map the normalized left/right and top/bottom balance into the
// safe interior of the illustrated board.
if (total < 0.5) 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={`absolute rounded bg-slate-950/80 px-1 py-0.5 text-center shadow ${className}`}>
<div className="text-[0.62rem] text-slate-400">{label}</div>
<div className="font-mono text-sm font-semibold text-slate-100">{formatWeight(value)}</div>
</div>
);
}
export default function BalanceBoardPanel() {
@@ -39,15 +67,35 @@ export default function BalanceBoardPanel() {
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 [adminMessage, setAdminMessage] = useState('');
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.emit('balanceBoard:subscribe', {}, () => {});
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]);
@@ -55,33 +103,95 @@ function BalanceBoardPanelContent() {
// Mask the previous reading immediately when disconnected. Keeping the last
// socket frame in state avoids effect-driven state resets and stale flashes.
const liveFrame = board?.connected ? frame : EMPTY_FRAME;
const battery = Number.isFinite(Number(liveFrame.batteryPercent))
? Number(liveFrame.batteryPercent)
: Number.isFinite(Number(board?.batteryPercent))
? Number(board.batteryPercent)
: null;
const presentation = statusPresentation(board?.status);
const actions = (
<span className={`rounded border px-1.5 py-0.5 text-xs font-semibold ${presentation.tone}`}>
{presentation.label}
</span>
);
const corners = { ...EMPTY_CORNERS, ...(liveFrame.corners || {}) };
const center = centerOfPressure(corners);
const liveBattery = finiteNumber(liveFrame.batteryPercent);
const sessionBattery = finiteNumber(board?.batteryPercent);
const battery = liveBattery ?? sessionBattery;
const sleeping = board?.status === 'sleeping';
const isAdmin = role === 'admin' || role === 'lockdown';
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);
setAdminMessage('');
socket.emit('balanceBoard:unpair', {}, (response = {}) => {
setUnpairing(false);
if (response.error) {
setAdminMessage(response.error);
} else if (response.warning) {
setAdminMessage('Board forgotten locally, but BlueZ reported a bond-removal warning.');
} else {
setAdminMessage('Board unpaired. Press the red Sync button to pair it again.');
}
});
};
return (
<CardFrame title="Balance Board" actions={actions} bodyClassName="p-2">
<div className="flex min-w-0 items-center justify-between gap-4 rounded border border-slate-700 bg-slate-950/50 p-2">
<div className="min-w-0">
<div className="font-mono text-4xl font-bold leading-none text-white">
{formatWeight(liveFrame.totalKg)}
<CardFrame
title="Balance Board"
className="relative w-full"
bodyClassName="text-sm text-slate-200"
actions={battery != null ? (
<span className="font-mono text-xs text-slate-300">{Math.round(battery)}% battery</span>
) : null}
>
{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">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>
<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>
{battery != null ? (
<div className="shrink-0 text-right text-xs text-slate-400">
Battery<br /><span className="font-mono text-base text-slate-200">{Math.round(battery)}%</span>
) : null}
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.25fr)_minmax(9rem,0.75fr)]">
<section className="surface-muted p-1">
<div className="relative mx-auto aspect-[1.35/1] w-full max-w-md overflow-hidden rounded-[1.5rem] border-2 border-slate-500 bg-gradient-to-b from-slate-700 to-slate-800 shadow-inner">
<div className="absolute inset-[12%] rounded-[1rem] border border-slate-500/70 bg-slate-900/35" />
<CornerReading className="left-1 top-1" label="Top left" value={corners.topLeft} />
<CornerReading className="right-1 top-1" label="Top right" value={corners.topRight} />
<CornerReading className="bottom-1 left-1" label="Bottom left" value={corners.bottomLeft} />
<CornerReading className="bottom-1 right-1" label="Bottom right" value={corners.bottomRight} />
<div
aria-label="Center of pressure"
className={`absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-lg transition-all duration-100 ${
center.active
? 'border-cyan-100 bg-cyan-400 shadow-cyan-400/50'
: 'border-slate-400 bg-slate-600 opacity-50'
}`}
style={{ left: `${center.left}%`, top: `${center.top}%` }}
/>
<div className="absolute bottom-0.5 left-1/2 h-1.5 w-8 -translate-x-1/2 rounded-full bg-sky-400/70" />
</div>
) : null}
</section>
<div className="grid content-start gap-0.5">
<section className="surface-muted px-1 py-1.5 text-center">
<div className="text-xs text-slate-400">Total weight</div>
<div className="mt-0.5 font-mono text-4xl font-bold leading-none text-white">
{formatWeight(liveFrame.totalKg)}
</div>
</section>
{isAdmin ? (
<section className="surface-muted grid gap-0.5 p-0.5">
{board?.address ? (
<div className="truncate text-center font-mono text-[0.65rem] text-slate-500">{board.address}</div>
) : null}
<button
type="button"
className="button-dark w-full text-xs disabled:opacity-50"
disabled={!board?.paired || unpairing}
onClick={unpair}
>
{unpairing ? 'Unpairing…' : 'Unpair board'}
</button>
{adminMessage ? <p className="text-center text-xs text-slate-400">{adminMessage}</p> : null}
</section>
) : null}
</div>
</div>
</CardFrame>
);
+1 -1
View File
@@ -421,11 +421,11 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard />
<LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel />
<OdometerPanel />
<ButtonBoxPanel />
<KinectPanel />
<BalanceBoardPanel />
</div>
</TabPanel>