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/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-U2WtZsRF.js"></script> <script type="module" crossorigin src="/assets/index-0zPVgUZU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BTjcgOin.css"> <link rel="stylesheet" crossorigin href="/assets/index-BqHv3u5e.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+221 -57
View File
@@ -1,6 +1,6 @@
// Balance Board Service // Balance Board Service
// Purpose: Exposes one Wii Balance Board as a self-pairing Bluetooth scale. // 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 fs = require('fs');
const { execFile } = require('child_process'); const { execFile } = require('child_process');
const { promisify } = require('util'); const { promisify } = require('util');
@@ -20,19 +20,43 @@ const rawConfig = loadConfig().balanceBoard || {};
const DATA_DIR = resolveDataDir(); const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('balance-board.json'); const STORE_PATH = resolveDataPath('balance-board.json');
const FRAME_ROOM = 'balance-board-viewers'; const FRAME_ROOM = 'balance-board-viewers';
const TARE_SAMPLE_COUNT = 20; const CORNER_KEYS = ['topRight', 'bottomRight', 'topLeft', 'bottomLeft'];
const MAX_AUTOMATIC_TARE_KG = 2; 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 execFileAsync = promisify(execFile);
const ALERT_COLOR = '#38bdf8'; 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() { function loadStore() {
try { try {
const parsed = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8')); const parsed = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : ''; 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) { } catch (err) {
if (err.code !== 'ENOENT') logger.warn('Failed to load Balance Board address', err.message); 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; 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 = {}) { function cornerWeightsKg(corners = {}) {
// Preserve all four factory-calibrated load cells in kilograms. Their // Preserve wiiuse's factory-calibrated load cells in kilograms. The separate
// relative distribution drives the center-of-pressure dot in the panel, // admin zero calibration below is an installation baseline layered on top of
// while the existing tare continues to apply only to the displayed total. // this factory conversion; it must never replace the hardware calibration.
return { return {
topRight: roundedWeight((Number(corners.topRight) || 0) / 100), topRight: roundedWeight((Number(corners.topRight) || 0) / 100),
bottomRight: roundedWeight((Number(corners.bottomRight) || 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 hardware = null;
let status = enabled ? (store.address ? 'waiting' : 'starting') : 'disabled'; let status = enabled ? (store.address ? 'waiting' : 'starting') : 'disabled';
let detail = enabled let detail = enabled
@@ -76,12 +104,27 @@ let detail = enabled
let connected = false; let connected = false;
let batteryPercent = null; let batteryPercent = null;
let latestFrame = null; let latestFrame = null;
let tareSamples = []; let latestRawCorners = null;
let tareKg = 0; let latestRawFrameAt = 0;
let zeroTimer = null;
let zeroSamples = [];
let zeroProgress = {
active: false,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
let previousWorkerState = ''; let previousWorkerState = '';
let lastAlertKey = ''; let lastAlertKey = '';
let unpairing = false; 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 = '') { function sendStatusAlert(workerState, message = '') {
const shouldAlert = const shouldAlert =
workerState === 'connected' || workerState === 'connected' ||
@@ -97,11 +140,7 @@ function sendStatusAlert(workerState, message = '') {
// state first, followed by its exact detail when one exists. The service does // state first, followed by its exact detail when one exists. The service does
// not reinterpret failures as friendlier product copy, but still collapses // not reinterpret failures as friendlier product copy, but still collapses
// identical retries so a failing reconnect cannot flood the activity feed. // identical retries so a failing reconnect cannot flood the activity feed.
const rawMessage = message ? `${workerState}: ${message}` : workerState; sendRawAlert(workerState, message);
const key = rawMessage;
if (key === lastAlertKey) return;
lastAlertKey = key;
sendAlert({ color: ALERT_COLOR, title: 'Balance Board', message: rawMessage });
} }
function getState() { function getState() {
@@ -113,6 +152,11 @@ function getState() {
status, status,
detail, detail,
batteryPercent, batteryPercent,
calibration: {
calibrated: Boolean(store.zeroedAt),
zeroedAt: store.zeroedAt,
...zeroProgress,
},
}; };
} }
@@ -125,28 +169,118 @@ function updateStatus(nextStatus, nextDetail) {
events.emit('change', { state: getState() }); 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 = {}) { function processFrame(message = {}) {
const rawKg = rawWeightKg(message.corners); const rawCorners = cornerWeightsKg(message.corners);
latestRawCorners = rawCorners;
latestRawFrameAt = Date.now();
if (Number.isFinite(Number(message.batteryPercent))) { if (Number.isFinite(Number(message.batteryPercent))) {
batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent))); batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent)));
} }
if (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; connected = true;
const zeroReady = tareSamples.length >= TARE_SAMPLE_COUNT; updateStatus('connected', 'Live weight is updating.');
updateStatus( const adjustedCorners = subtractZero(rawCorners);
zeroReady ? 'connected' : 'zeroing',
zeroReady ? 'Live weight is updating.' : 'Keep the board empty for one second while it zeros.',
);
latestFrame = { latestFrame = {
totalKg: roundedWeight(rawKg - tareKg), totalKg: totalCornerWeight(adjustedCorners),
corners: cornerWeightsKg(message.corners), corners: adjustedCorners,
batteryPercent, batteryPercent,
}; };
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame); 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() : ''; const address = typeof message.address === 'string' ? message.address.trim().toUpperCase() : '';
if (address && address !== store.address) { if (address && address !== store.address) {
store.address = 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(); persistStore();
} }
hardware?.setAddress(address); hardware?.setAddress(address);
lastAlertKey = 'paired'; sendRawAlert('paired');
sendAlert({
color: ALERT_COLOR,
title: 'Balance Board',
message: 'paired',
});
updateStatus('connecting', 'Paired. Connecting to the board now.'); updateStatus('connecting', 'Paired. Connecting to the board now.');
return; return;
} }
@@ -186,9 +320,7 @@ function handleWorkerMessage(message = {}) {
updateStatus('pairing', 'Board found. Pairing now.'); updateStatus('pairing', 'Board found. Pairing now.');
} else if (workerState === 'connected') { } else if (workerState === 'connected') {
connected = true; connected = true;
tareSamples = []; updateStatus('connected', 'Connected. Waiting for live weight data.');
tareKg = 0;
updateStatus('zeroing', 'Connected. Keep the board empty for one second while it zeros.');
} else if (workerState === 'link-detected') { } else if (workerState === 'link-detected') {
connected = false; connected = false;
// The native bridge can now distinguish which half of the board's HID // 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') { } else if (workerState === 'connection-failed') {
connected = false; connected = false;
latestFrame = null; 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.'); updateStatus('connection-failed', message.error || 'The direct Balance Board connection failed.');
} else if (workerState === 'sleeping') { } else if (workerState === 'sleeping') {
connected = false; connected = false;
latestFrame = null; 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.'); updateStatus('sleeping', message.error || 'Board is asleep. Press the front power button to wake it.');
} else if (workerState === 'waiting') { } else if (workerState === 'waiting') {
connected = false; connected = false;
latestFrame = null; 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.'); updateStatus('waiting', message.error || 'Press the front power button. The server will keep trying to connect.');
} else if (workerState === 'error') { } else if (workerState === 'error') {
connected = false; connected = false;
latestRawCorners = null;
latestRawFrameAt = 0;
if (zeroProgress.active) failZeroCalibration('Worker stopped during calibration.');
updateStatus('error', message.error || 'The Balance Board worker stopped.'); updateStatus('error', message.error || 'The Balance Board worker stopped.');
} }
} }
@@ -220,6 +364,18 @@ io.on('connection', (socket) => {
cb({ success: true }); cb({ success: true });
}); });
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM)); socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
socket.on('balanceBoard: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 = () => {}) => { socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
if (!isAdmin(socket)) { if (!isAdmin(socket)) {
cb({ error: 'Admin access required' }); cb({ error: 'Admin access required' });
@@ -249,22 +405,27 @@ io.on('connection', (socket) => {
} }
store.address = ''; store.address = '';
store.zeroCorners = emptyZeroCorners();
store.zeroedAt = null;
persistStore(); persistStore();
clearZeroTimer();
zeroSamples = [];
zeroProgress = {
active: false,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
connected = false; connected = false;
batteryPercent = null; batteryPercent = null;
latestFrame = null; latestFrame = null;
tareSamples = []; latestRawCorners = null;
tareKg = 0; latestRawFrameAt = 0;
previousWorkerState = ''; previousWorkerState = '';
lastAlertKey = 'unpaired';
hardware?.setAddress(''); hardware?.setAddress('');
hardware?.restart(); hardware?.restart();
updateStatus('starting', 'Starting Bluetooth discovery.'); updateStatus('starting', 'Starting Bluetooth discovery.');
sendAlert({ sendRawAlert('unpaired');
color: ALERT_COLOR,
title: 'Balance Board',
message: 'unpaired',
});
cb({ success: true, warning: bluetoothWarning || null }); cb({ success: true, warning: bluetoothWarning || null });
} catch (err) { } catch (err) {
logger.error('Failed to unpair Balance Board', err); logger.error('Failed to unpair Balance Board', err);
@@ -288,7 +449,10 @@ if (enabled) {
} }
function installShutdownHooks() { function installShutdownHooks() {
const shutdown = () => hardware?.stop(); const shutdown = () => {
clearZeroTimer();
hardware?.stop();
};
process.once('exit', shutdown); process.once('exit', shutdown);
process.once('SIGINT', shutdown); process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown); process.once('SIGTERM', shutdown);
@@ -82,8 +82,8 @@ constexpr uint16_t kHidInterruptPsm = 0x0013;
constexpr int kCommissioningConnectWindowMs = 15000; constexpr int kCommissioningConnectWindowMs = 15000;
constexpr int kIncomingChannelPairTimeoutMs = 5000; constexpr int kIncomingChannelPairTimeoutMs = 5000;
constexpr int kHandshakeWarningMs = 10000; constexpr int kHandshakeWarningMs = 10000;
constexpr int kEmptyWeightThresholdCentiKg = 200; constexpr int kMovementThresholdCentiKg = 50;
constexpr int kEmptySleepDelayMs = 2 * 60 * 1000; constexpr int kStillSleepDelayMs = 2 * 60 * 1000;
std::atomic<bool> running{true}; std::atomic<bool> running{true};
std::mutex output_mutex; std::mutex output_mutex;
@@ -981,8 +981,9 @@ void direct_connection_loop(PairingSharedState* shared, wiimote_t** boards) {
bool board_ready = false; bool board_ready = false;
bool handshake_warning_sent = false; bool handshake_warning_sent = false;
bool intentional_sleep = false; bool intentional_sleep = false;
std::optional<uint64_t> empty_since; std::optional<BoardReadings> activity_reference;
uint64_t connected_at = monotonic_ms(); uint64_t connected_at = monotonic_ms();
uint64_t last_movement_at = connected_at;
uint64_t last_frame_at = 0; uint64_t last_frame_at = 0;
while (running.load() && WIIMOTE_IS_CONNECTED(board)) { while (running.load() && WIIMOTE_IS_CONNECTED(board)) {
wiiuse_poll(boards, 1); wiiuse_poll(boards, 1);
@@ -1005,7 +1006,8 @@ void direct_connection_loop(PairingSharedState* shared, wiimote_t** boards) {
if (now - last_frame_at >= kFrameIntervalMs) { if (now - last_frame_at >= kFrameIntervalMs) {
// Wiiuse interpolates each sensor using the board's factory 0/17/34kg // Wiiuse interpolates each sensor using the board's factory 0/17/34kg
// calibration values. Preserve the existing centi-kilogram wire unit // 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; const wii_board_t& weights = board->exp.wb;
BoardReadings readings{ BoardReadings readings{
static_cast<int>(std::lround(std::max(0.0F, weights.tr) * 100.0F)), 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); emit_frame(readings, battery);
last_frame_at = now; last_frame_at = now;
const int total_weight = readings.top_right + readings.bottom_right + if (!activity_reference.has_value()) {
readings.top_left + readings.bottom_left; activity_reference = readings;
if (total_weight > kEmptyWeightThresholdCentiKg) { last_movement_at = now;
// A person, rover, or other load immediately restarts the complete } else {
// two-minute idle window. Short empty gaps can never accumulate and // Compare against the last meaningful activity snapshot rather
// shut the board down while it is actively being used. // than the immediately previous frame. That lets slow movement
empty_since.reset(); // accumulate past the noise threshold while ordinary sensor jitter
} else if (!empty_since.has_value()) { // cannot keep the board awake forever. All four corners matter, so
empty_since = now; // shifting a load without changing total weight still counts.
} else if (now - *empty_since >= kEmptySleepDelayMs) { 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; intentional_sleep = true;
emit_status("sleeping", *address, emit_status("sleeping", *address,
"Board is asleep. Press the front power button to wake it."); "Board is asleep. Press the front power button to wake it.");
@@ -51,7 +51,7 @@ function CornerReading({ className, label, value }) {
return ( return (
<div className={`surface absolute min-w-[5.5rem] text-center ${className}`}> <div className={`surface absolute min-w-[5.5rem] text-center ${className}`}>
<div className="text-[0.62rem] text-slate-400">{label}</div> <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> </div>
); );
} }
@@ -70,6 +70,7 @@ function BalanceBoardPanelContent() {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const [frame, setFrame] = useState(EMPTY_FRAME); const [frame, setFrame] = useState(EMPTY_FRAME);
const [unpairing, setUnpairing] = useState(false); const [unpairing, setUnpairing] = useState(false);
const [zeroRequesting, setZeroRequesting] = useState(false);
useEffect(() => { useEffect(() => {
if (!socket) return undefined; if (!socket) return undefined;
@@ -109,6 +110,18 @@ function BalanceBoardPanelContent() {
const battery = liveBattery ?? sessionBattery; const battery = liveBattery ?? sessionBattery;
const sleeping = board?.status === 'sleeping'; const sleeping = board?.status === 'sleeping';
const isAdmin = role === 'admin' || role === 'lockdown'; 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 = () => { const unpair = () => {
if (unpairing || !board?.paired) return; if (unpairing || !board?.paired) return;
@@ -127,17 +140,29 @@ function BalanceBoardPanelContent() {
const actions = ( const actions = (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{battery != null ? ( {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} ) : null}
{isAdmin ? ( {isAdmin ? (
<button <>
type="button" <button
className="button-dark text-xs disabled:opacity-50" type="button"
disabled={!board?.paired || unpairing} className="button-dark text-xs disabled:opacity-50"
onClick={unpair} disabled={!board?.connected || zeroRequesting || zeroing || unpairing}
> onClick={zero}
{unpairing ? 'Unpairing…' : 'Unpair'} >
</button> {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} ) : null}
</div> </div>
); );
@@ -158,7 +183,17 @@ function BalanceBoardPanelContent() {
</div> </div>
) : null} ) : 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="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="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 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="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="surface px-1 py-0.5 text-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-[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)} {formatWeight(liveFrame.totalKg)}
</div> </div>
</div> </div>