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/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-0zPVgUZU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BqHv3u5e.css">
<script type="module" crossorigin src="/assets/index-B056JgJR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BcBTKEa5.css">
</head>
<body>
<div id="root"></div>
@@ -25,6 +25,7 @@ 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 RECORD_PERSIST_DELAY_MS = 1000;
const execFileAsync = promisify(execFile);
const ALERT_COLOR = '#38bdf8';
@@ -41,7 +42,13 @@ function normalizeStoredCorners(value) {
}
function emptyStore() {
return { address: '', zeroCorners: emptyZeroCorners(), zeroedAt: null };
return {
address: '',
zeroCorners: emptyZeroCorners(),
zeroedAt: null,
recordKg: 0,
recordedAt: null,
};
}
function loadStore() {
@@ -49,10 +56,18 @@ function loadStore() {
const parsed = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
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 {
address,
zeroCorners: zeroedAt ? normalizeStoredCorners(parsed.zeroCorners) : emptyZeroCorners(),
zeroedAt,
recordKg,
recordedAt: recordKg > 0 ? recordedAt : null,
};
} catch (err) {
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 latestRawFrameAt = 0;
let zeroTimer = null;
let recordPersistTimer = null;
let zeroSamples = [];
let zeroProgress = {
active: false,
@@ -152,6 +168,8 @@ function getState() {
status,
detail,
batteryPercent,
recordKg: store.recordKg,
recordedAt: store.recordedAt,
calibration: {
calibrated: Boolean(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) {
const normalizedStatus = String(nextStatus || 'unknown');
const normalizedDetail = String(nextDetail || '');
@@ -218,6 +285,11 @@ function finishZeroCalibration() {
return [key, Math.round(average * 1000) / 1000];
}));
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();
zeroSamples = [];
zeroProgress = {
@@ -278,12 +350,22 @@ function processFrame(message = {}) {
connected = true;
updateStatus('connected', 'Live weight is updating.');
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 = {
totalKg: totalCornerWeight(adjustedCorners),
totalKg,
corners: adjustedCorners,
batteryPercent,
recordKg: store.recordKg,
recordedAt: store.recordedAt,
};
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
publishLatestFrame();
}
function handleWorkerMessage(message = {}) {
@@ -301,6 +383,9 @@ function handleWorkerMessage(message = {}) {
// baseline across commissioning a different Bluetooth identity.
store.zeroCorners = emptyZeroCorners();
store.zeroedAt = null;
clearRecordPersistTimer();
store.recordKg = 0;
store.recordedAt = null;
persistStore();
}
hardware?.setAddress(address);
@@ -376,6 +461,20 @@ io.on('connection', (socket) => {
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 = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
@@ -407,6 +506,9 @@ io.on('connection', (socket) => {
store.address = '';
store.zeroCorners = emptyZeroCorners();
store.zeroedAt = null;
clearRecordPersistTimer();
store.recordKg = 0;
store.recordedAt = null;
persistStore();
clearZeroTimer();
zeroSamples = [];
@@ -451,6 +553,13 @@ if (enabled) {
function installShutdownHooks() {
const shutdown = () => {
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();
};
process.once('exit', shutdown);
+104 -55
View File
@@ -13,7 +13,16 @@ const EMPTY_CORNERS = {
bottomLeft: 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) {
const weight = Number(value);
@@ -71,6 +80,7 @@ function BalanceBoardPanelContent() {
const [frame, setFrame] = useState(EMPTY_FRAME);
const [unpairing, setUnpairing] = useState(false);
const [zeroRequesting, setZeroRequesting] = useState(false);
const [resettingRecord, setResettingRecord] = useState(false);
useEffect(() => {
if (!socket) return undefined;
@@ -108,6 +118,13 @@ function BalanceBoardPanelContent() {
const liveBattery = finiteNumber(liveFrame.batteryPercent);
const sessionBattery = finiteNumber(board?.batteryPercent);
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 isAdmin = role === 'admin' || role === 'lockdown';
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">
{battery != null ? (
<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?.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}
<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>
</div>
);
) : null;
return (
<CardFrame
@@ -183,35 +203,64 @@ function BalanceBoardPanelContent() {
</div>
) : null}
<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>
{/* Keep the measurement column narrow and fixed so the board remains the
dominant visual while record and battery stay in one predictable
place. Both pieces use the shared dark panel treatment instead of
introducing a Balance Board-specific background style. */}
<div className="grid grid-cols-[minmax(0,1fr)_8rem] gap-0.5">
<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} />
<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>
) : 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 className="grid h-52 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
<div className="panel-section flex min-h-0 flex-col items-center justify-center gap-1 text-center">
<div className="text-xs text-slate-400">Weight record</div>
<div className="text-xl font-bold text-white">{formatWeight(record)}</div>
{isAdmin ? (
<button
type="button"
className="button-dark text-xs disabled:opacity-50"
disabled={resettingRecord}
onClick={resetRecord}
>
{resettingRecord ? 'Resetting…' : 'Reset'}
</button>
) : null}
</div>
<div className="panel-section px-1 py-1 text-center">
<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>