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) {