This commit is contained in:
legop3
2026-07-17 01:22:27 -04:00
parent 557b4b81a2
commit 12090f23be
9 changed files with 310 additions and 98 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-U2WtZsRF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BTjcgOin.css">
<script type="module" crossorigin src="/assets/index-0zPVgUZU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BqHv3u5e.css">
</head>
<body>
<div id="root"></div>
+221 -57
View File
@@ -1,6 +1,6 @@
// Balance Board Service
// 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.
// 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');
@@ -20,19 +20,43 @@ const rawConfig = loadConfig().balanceBoard || {};
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('balance-board.json');
const FRAME_ROOM = 'balance-board-viewers';
const TARE_SAMPLE_COUNT = 20;
const MAX_AUTOMATIC_TARE_KG = 2;
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 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 };
}
function loadStore() {
try {
const parsed = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
return { address };
const zeroedAt = Number.isFinite(Number(parsed?.zeroedAt)) ? Number(parsed.zeroedAt) : null;
return {
address,
zeroCorners: zeroedAt ? normalizeStoredCorners(parsed.zeroCorners) : emptyZeroCorners(),
zeroedAt,
};
} catch (err) {
if (err.code !== 'ENOENT') logger.warn('Failed to load Balance Board address', err.message);
return { address: '' };
return emptyStore();
}
}
@@ -47,18 +71,10 @@ function roundedWeight(value) {
return Math.round(Math.max(0, Number(value) || 0) * 100) / 100;
}
function rawWeightKg(corners = {}) {
// The native wiiuse bridge reports each calibrated load cell in
// centi-kilograms. The UI only needs total scale weight, so sum and convert
// at this single boundary.
return ['topRight', 'bottomRight', 'topLeft', 'bottomLeft']
.reduce((total, key) => total + Math.max(0, Number(corners[key]) || 0), 0) / 100;
}
function 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.
// 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),
@@ -67,7 +83,19 @@ function cornerWeightsKg(corners = {}) {
};
}
let store = enabled ? loadStore() : { address: '' };
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
@@ -76,12 +104,27 @@ let detail = enabled
let connected = false;
let batteryPercent = null;
let latestFrame = null;
let tareSamples = [];
let tareKg = 0;
let latestRawCorners = null;
let latestRawFrameAt = 0;
let zeroTimer = 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' ||
@@ -97,11 +140,7 @@ function sendStatusAlert(workerState, message = '') {
// 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.
const rawMessage = message ? `${workerState}: ${message}` : workerState;
const key = rawMessage;
if (key === lastAlertKey) return;
lastAlertKey = key;
sendAlert({ color: ALERT_COLOR, title: 'Balance Board', message: rawMessage });
sendRawAlert(workerState, message);
}
function getState() {
@@ -113,6 +152,11 @@ function getState() {
status,
detail,
batteryPercent,
calibration: {
calibrated: Boolean(store.zeroedAt),
zeroedAt: store.zeroedAt,
...zeroProgress,
},
};
}
@@ -125,28 +169,118 @@ function updateStatus(nextStatus, nextDetail) {
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();
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 rawKg = rawWeightKg(message.corners);
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)));
}
if (tareSamples.length < TARE_SAMPLE_COUNT && rawKg <= MAX_AUTOMATIC_TARE_KG) {
tareSamples.push(rawKg);
if (tareSamples.length === TARE_SAMPLE_COUNT) {
tareKg = tareSamples.reduce((sum, value) => sum + value, 0) / tareSamples.length;
}
}
connected = true;
const zeroReady = tareSamples.length >= TARE_SAMPLE_COUNT;
updateStatus(
zeroReady ? 'connected' : 'zeroing',
zeroReady ? 'Live weight is updating.' : 'Keep the board empty for one second while it zeros.',
);
updateStatus('connected', 'Live weight is updating.');
const adjustedCorners = subtractZero(rawCorners);
latestFrame = {
totalKg: roundedWeight(rawKg - tareKg),
corners: cornerWeightsKg(message.corners),
totalKg: totalCornerWeight(adjustedCorners),
corners: adjustedCorners,
batteryPercent,
};
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
@@ -162,15 +296,15 @@ function handleWorkerMessage(message = {}) {
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;
persistStore();
}
hardware?.setAddress(address);
lastAlertKey = 'paired';
sendAlert({
color: ALERT_COLOR,
title: 'Balance Board',
message: 'paired',
});
sendRawAlert('paired');
updateStatus('connecting', 'Paired. Connecting to the board now.');
return;
}
@@ -186,9 +320,7 @@ function handleWorkerMessage(message = {}) {
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.');
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
@@ -198,17 +330,29 @@ function handleWorkerMessage(message = {}) {
} 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.');
}
}
@@ -220,6 +364,18 @@ io.on('connection', (socket) => {
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:unpair', async (_payload = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
@@ -249,22 +405,27 @@ io.on('connection', (socket) => {
}
store.address = '';
store.zeroCorners = emptyZeroCorners();
store.zeroedAt = null;
persistStore();
clearZeroTimer();
zeroSamples = [];
zeroProgress = {
active: false,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
connected = false;
batteryPercent = null;
latestFrame = null;
tareSamples = [];
tareKg = 0;
latestRawCorners = null;
latestRawFrameAt = 0;
previousWorkerState = '';
lastAlertKey = 'unpaired';
hardware?.setAddress('');
hardware?.restart();
updateStatus('starting', 'Starting Bluetooth discovery.');
sendAlert({
color: ALERT_COLOR,
title: 'Balance Board',
message: 'unpaired',
});
sendRawAlert('unpaired');
cb({ success: true, warning: bluetoothWarning || null });
} catch (err) {
logger.error('Failed to unpair Balance Board', err);
@@ -288,7 +449,10 @@ if (enabled) {
}
function installShutdownHooks() {
const shutdown = () => hardware?.stop();
const shutdown = () => {
clearZeroTimer();
hardware?.stop();
};
process.once('exit', shutdown);
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
@@ -82,8 +82,8 @@ constexpr uint16_t kHidInterruptPsm = 0x0013;
constexpr int kCommissioningConnectWindowMs = 15000;
constexpr int kIncomingChannelPairTimeoutMs = 5000;
constexpr int kHandshakeWarningMs = 10000;
constexpr int kEmptyWeightThresholdCentiKg = 200;
constexpr int kEmptySleepDelayMs = 2 * 60 * 1000;
constexpr int kMovementThresholdCentiKg = 50;
constexpr int kStillSleepDelayMs = 2 * 60 * 1000;
std::atomic<bool> running{true};
std::mutex output_mutex;
@@ -981,8 +981,9 @@ void direct_connection_loop(PairingSharedState* shared, wiimote_t** boards) {
bool board_ready = false;
bool handshake_warning_sent = false;
bool intentional_sleep = false;
std::optional<uint64_t> empty_since;
std::optional<BoardReadings> activity_reference;
uint64_t connected_at = monotonic_ms();
uint64_t last_movement_at = connected_at;
uint64_t last_frame_at = 0;
while (running.load() && WIIMOTE_IS_CONNECTED(board)) {
wiiuse_poll(boards, 1);
@@ -1005,7 +1006,8 @@ void direct_connection_loop(PairingSharedState* shared, wiimote_t** boards) {
if (now - last_frame_at >= kFrameIntervalMs) {
// Wiiuse interpolates each sensor using the board's factory 0/17/34kg
// calibration values. Preserve the existing centi-kilogram wire unit
// so Node's tare and total-weight calculation remain straightforward.
// so Node can apply its persisted installation zero per corner without
// losing the native sensor resolution.
const wii_board_t& weights = board->exp.wb;
BoardReadings readings{
static_cast<int>(std::lround(std::max(0.0F, weights.tr) * 100.0F)),
@@ -1018,16 +1020,27 @@ void direct_connection_loop(PairingSharedState* shared, wiimote_t** boards) {
emit_frame(readings, battery);
last_frame_at = now;
const int total_weight = readings.top_right + readings.bottom_right +
readings.top_left + readings.bottom_left;
if (total_weight > kEmptyWeightThresholdCentiKg) {
// A person, rover, or other load immediately restarts the complete
// two-minute idle window. Short empty gaps can never accumulate and
// shut the board down while it is actively being used.
empty_since.reset();
} else if (!empty_since.has_value()) {
empty_since = now;
} else if (now - *empty_since >= kEmptySleepDelayMs) {
if (!activity_reference.has_value()) {
activity_reference = readings;
last_movement_at = now;
} else {
// Compare against the last meaningful activity snapshot rather
// than the immediately previous frame. That lets slow movement
// accumulate past the noise threshold while ordinary sensor jitter
// cannot keep the board awake forever. All four corners matter, so
// shifting a load without changing total weight still counts.
const int movement =
std::abs(readings.top_right - activity_reference->top_right) +
std::abs(readings.bottom_right - activity_reference->bottom_right) +
std::abs(readings.top_left - activity_reference->top_left) +
std::abs(readings.bottom_left - activity_reference->bottom_left);
if (movement >= kMovementThresholdCentiKg) {
activity_reference = readings;
last_movement_at = now;
}
}
if (now - last_movement_at >= kStillSleepDelayMs) {
intentional_sleep = true;
emit_status("sleeping", *address,
"Board is asleep. Press the front power button to wake it.");
@@ -51,7 +51,7 @@ 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="font-mono text-sm font-semibold text-slate-100">{formatWeight(value)}</div>
<div className="text-sm font-semibold text-slate-100">{formatWeight(value)}</div>
</div>
);
}
@@ -70,6 +70,7 @@ function BalanceBoardPanelContent() {
const role = useSessionSelector((state) => state.session?.role || null);
const [frame, setFrame] = useState(EMPTY_FRAME);
const [unpairing, setUnpairing] = useState(false);
const [zeroRequesting, setZeroRequesting] = useState(false);
useEffect(() => {
if (!socket) return undefined;
@@ -109,6 +110,18 @@ function BalanceBoardPanelContent() {
const battery = liveBattery ?? sessionBattery;
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 boards 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;
@@ -127,17 +140,29 @@ function BalanceBoardPanelContent() {
const actions = (
<div className="flex items-center gap-0.5">
{battery != null ? (
<span className="font-mono text-xs text-slate-300">{Math.round(battery)}% battery</span>
<span className="text-xs text-slate-300">{Math.round(battery)}% battery</span>
) : null}
{isAdmin ? (
<button
type="button"
className="button-dark text-xs disabled:opacity-50"
disabled={!board?.paired || unpairing}
onClick={unpair}
>
{unpairing ? 'Unpairing…' : 'Unpair'}
</button>
<>
<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>
</>
) : null}
</div>
);
@@ -158,7 +183,17 @@ function BalanceBoardPanelContent() {
</div>
) : null}
<div className="panel-section relative h-40 overflow-hidden">
<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} />
@@ -175,7 +210,7 @@ function BalanceBoardPanelContent() {
<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="font-mono text-3xl font-bold leading-none text-white">
<div className="text-3xl font-bold leading-none text-white">
{formatWeight(liveFrame.totalKg)}
</div>
</div>