This commit is contained in:
legop3
2026-07-17 01:55:59 -04:00
parent 12090f23be
commit 8655cde0f1
8 changed files with 230 additions and 72 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-0zPVgUZU.js"></script> <script type="module" crossorigin src="/assets/index-B056JgJR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BqHv3u5e.css"> <link rel="stylesheet" crossorigin href="/assets/index-BcBTKEa5.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -25,6 +25,7 @@ const ZERO_SAMPLE_COUNT = 10;
const ZERO_SAMPLE_INTERVAL_MS = 1000; const ZERO_SAMPLE_INTERVAL_MS = 1000;
const ZERO_MAX_SAMPLE_AGE_MS = 1500; const ZERO_MAX_SAMPLE_AGE_MS = 1500;
const ZERO_MAX_COMBINED_RANGE_KG = 0.5; const ZERO_MAX_COMBINED_RANGE_KG = 0.5;
const RECORD_PERSIST_DELAY_MS = 1000;
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const ALERT_COLOR = '#38bdf8'; const ALERT_COLOR = '#38bdf8';
@@ -41,7 +42,13 @@ function normalizeStoredCorners(value) {
} }
function emptyStore() { function emptyStore() {
return { address: '', zeroCorners: emptyZeroCorners(), zeroedAt: null }; return {
address: '',
zeroCorners: emptyZeroCorners(),
zeroedAt: null,
recordKg: 0,
recordedAt: null,
};
} }
function loadStore() { function loadStore() {
@@ -49,10 +56,18 @@ function loadStore() {
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() : '';
const zeroedAt = Number.isFinite(Number(parsed?.zeroedAt)) ? Number(parsed.zeroedAt) : null; 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 { return {
address, address,
zeroCorners: zeroedAt ? normalizeStoredCorners(parsed.zeroCorners) : emptyZeroCorners(), zeroCorners: zeroedAt ? normalizeStoredCorners(parsed.zeroCorners) : emptyZeroCorners(),
zeroedAt, zeroedAt,
recordKg,
recordedAt: recordKg > 0 ? recordedAt : null,
}; };
} 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);
@@ -107,6 +122,7 @@ let latestFrame = null;
let latestRawCorners = null; let latestRawCorners = null;
let latestRawFrameAt = 0; let latestRawFrameAt = 0;
let zeroTimer = null; let zeroTimer = null;
let recordPersistTimer = null;
let zeroSamples = []; let zeroSamples = [];
let zeroProgress = { let zeroProgress = {
active: false, active: false,
@@ -152,6 +168,8 @@ function getState() {
status, status,
detail, detail,
batteryPercent, batteryPercent,
recordKg: store.recordKg,
recordedAt: store.recordedAt,
calibration: { calibration: {
calibrated: Boolean(store.zeroedAt), calibrated: Boolean(store.zeroedAt),
zeroedAt: store.zeroedAt, zeroedAt: store.zeroedAt,
@@ -160,6 +178,55 @@ function getState() {
}; };
} }
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) { function updateStatus(nextStatus, nextDetail) {
const normalizedStatus = String(nextStatus || 'unknown'); const normalizedStatus = String(nextStatus || 'unknown');
const normalizedDetail = String(nextDetail || ''); const normalizedDetail = String(nextDetail || '');
@@ -218,6 +285,11 @@ function finishZeroCalibration() {
return [key, Math.round(average * 1000) / 1000]; return [key, Math.round(average * 1000) / 1000];
})); }));
store.zeroedAt = Date.now(); 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(); persistStore();
zeroSamples = []; zeroSamples = [];
zeroProgress = { zeroProgress = {
@@ -278,12 +350,22 @@ function processFrame(message = {}) {
connected = true; connected = true;
updateStatus('connected', 'Live weight is updating.'); updateStatus('connected', 'Live weight is updating.');
const adjustedCorners = subtractZero(rawCorners); 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 = { latestFrame = {
totalKg: totalCornerWeight(adjustedCorners), totalKg,
corners: adjustedCorners, corners: adjustedCorners,
batteryPercent, batteryPercent,
recordKg: store.recordKg,
recordedAt: store.recordedAt,
}; };
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame); publishLatestFrame();
} }
function handleWorkerMessage(message = {}) { function handleWorkerMessage(message = {}) {
@@ -301,6 +383,9 @@ function handleWorkerMessage(message = {}) {
// baseline across commissioning a different Bluetooth identity. // baseline across commissioning a different Bluetooth identity.
store.zeroCorners = emptyZeroCorners(); store.zeroCorners = emptyZeroCorners();
store.zeroedAt = null; store.zeroedAt = null;
clearRecordPersistTimer();
store.recordKg = 0;
store.recordedAt = null;
persistStore(); persistStore();
} }
hardware?.setAddress(address); hardware?.setAddress(address);
@@ -376,6 +461,20 @@ io.on('connection', (socket) => {
cb({ error: err.message || 'Failed to start Balance Board zero calibration' }); 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 = () => {}) => { socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
if (!isAdmin(socket)) { if (!isAdmin(socket)) {
cb({ error: 'Admin access required' }); cb({ error: 'Admin access required' });
@@ -407,6 +506,9 @@ io.on('connection', (socket) => {
store.address = ''; store.address = '';
store.zeroCorners = emptyZeroCorners(); store.zeroCorners = emptyZeroCorners();
store.zeroedAt = null; store.zeroedAt = null;
clearRecordPersistTimer();
store.recordKg = 0;
store.recordedAt = null;
persistStore(); persistStore();
clearZeroTimer(); clearZeroTimer();
zeroSamples = []; zeroSamples = [];
@@ -451,6 +553,13 @@ if (enabled) {
function installShutdownHooks() { function installShutdownHooks() {
const shutdown = () => { const shutdown = () => {
clearZeroTimer(); 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(); hardware?.stop();
}; };
process.once('exit', shutdown); process.once('exit', shutdown);
+104 -55
View File
@@ -13,7 +13,16 @@ const EMPTY_CORNERS = {
bottomLeft: 0, bottomLeft: 0,
bottomRight: 0, bottomRight: 0,
}; };
const EMPTY_FRAME = { totalKg: 0, batteryPercent: null, corners: EMPTY_CORNERS }; 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) { function formatWeight(value) {
const weight = Number(value); const weight = Number(value);
@@ -71,6 +80,7 @@ function BalanceBoardPanelContent() {
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); const [zeroRequesting, setZeroRequesting] = useState(false);
const [resettingRecord, setResettingRecord] = useState(false);
useEffect(() => { useEffect(() => {
if (!socket) return undefined; if (!socket) return undefined;
@@ -108,6 +118,13 @@ function BalanceBoardPanelContent() {
const liveBattery = finiteNumber(liveFrame.batteryPercent); const liveBattery = finiteNumber(liveFrame.batteryPercent);
const sessionBattery = finiteNumber(board?.batteryPercent); const sessionBattery = finiteNumber(board?.batteryPercent);
const battery = liveBattery ?? sessionBattery; 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 sleeping = board?.status === 'sleeping';
const isAdmin = role === 'admin' || role === 'lockdown'; const isAdmin = role === 'admin' || role === 'lockdown';
const calibration = board?.calibration || null; const calibration = board?.calibration || null;
@@ -137,35 +154,38 @@ function BalanceBoardPanelContent() {
}); });
}; };
const actions = ( 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"> <div className="flex items-center gap-0.5">
{battery != null ? ( <button
<span className="text-xs text-slate-300">{Math.round(battery)}% battery</span> type="button"
) : null} className="button-dark text-xs disabled:opacity-50"
{isAdmin ? ( disabled={!board?.connected || zeroRequesting || zeroing || unpairing}
<> onClick={zero}
<button >
type="button" {zeroing
className="button-dark text-xs disabled:opacity-50" ? `Zeroing ${calibration.samplesCollected}/${calibration.totalSamples}`
disabled={!board?.connected || zeroRequesting || zeroing || unpairing} : zeroRequesting ? 'Starting…' : 'Zero'}
onClick={zero} </button>
> <button
{zeroing type="button"
? `Zeroing ${calibration.samplesCollected}/${calibration.totalSamples}` className="button-dark text-xs disabled:opacity-50"
: zeroRequesting ? 'Starting…' : 'Zero'} disabled={!board?.paired || unpairing || zeroing}
</button> onClick={unpair}
<button >
type="button" {unpairing ? 'Unpairing…' : 'Unpair'}
className="button-dark text-xs disabled:opacity-50" </button>
disabled={!board?.paired || unpairing || zeroing}
onClick={unpair}
>
{unpairing ? 'Unpairing…' : 'Unpair'}
</button>
</>
) : null}
</div> </div>
); ) : null;
return ( return (
<CardFrame <CardFrame
@@ -183,35 +203,64 @@ function BalanceBoardPanelContent() {
</div> </div>
) : null} ) : null}
<div className="panel-section relative h-52 overflow-hidden"> {/* Keep the measurement column narrow and fixed so the board remains the
{zeroing ? ( dominant visual while record and battery stay in one predictable
<div className="absolute inset-0 z-20 flex items-center justify-center bg-neutral-950/90 px-2 text-center"> place. Both pieces use the shared dark panel treatment instead of
<div className="space-y-0.5"> introducing a Balance Board-specific background style. */}
<p className="text-lg font-semibold text-slate-100"> <div className="grid grid-cols-[minmax(0,1fr)_8rem] gap-0.5">
Zeroing {calibration.samplesCollected}/{calibration.totalSamples} <div className="panel-section relative h-52 overflow-hidden">
</p> {zeroing ? (
<p className="text-sm text-slate-300">Keep the board and everything on it still.</p> <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>
) : null} </div>
<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} /> <div className="grid h-52 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
<CornerReading className="bottom-0.5 left-0.5" label="Bottom left" value={corners.bottomLeft} /> <div className="panel-section flex min-h-0 flex-col items-center justify-center gap-1 text-center">
<CornerReading className="bottom-0.5 right-0.5" label="Bottom right" value={corners.bottomRight} /> <div className="text-xs text-slate-400">Weight record</div>
<div <div className="text-xl font-bold text-white">{formatWeight(record)}</div>
aria-label="Center of pressure" {isAdmin ? (
className={`absolute z-10 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border transition-all duration-100 ${ <button
center.active type="button"
? 'border-sky-200 bg-sky-500' className="button-dark text-xs disabled:opacity-50"
: 'border-neutral-500 bg-neutral-600 opacity-50' disabled={resettingRecord}
}`} onClick={resetRecord}
style={{ left: `${center.left}%`, top: `${center.top}%` }} >
/> {resettingRecord ? 'Resetting…' : 'Reset'}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> </button>
<div className="surface px-1 py-0.5 text-center"> ) : null}
<div className="text-[0.65rem] text-slate-400">Total weight</div> </div>
<div className="text-3xl font-bold leading-none text-white"> <div className="panel-section px-1 py-1 text-center">
{formatWeight(liveFrame.totalKg)} <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>
</div> </div>