This commit is contained in:
legop3
2026-07-16 22:26:38 -04:00
parent a6c569ada4
commit 8a9205b5b7
8 changed files with 365 additions and 56 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
+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-0ANoPaoC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D6ALZM8p.css">
<script type="module" crossorigin src="/assets/index-BUKu-zuJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BxYDUK4v.css">
</head>
<body>
<div id="root"></div>
@@ -71,6 +71,19 @@ let lastError = null;
let batteryPercent = null;
let latestFrame = null;
let lastMeasurement = null;
let bluetooth = {
available: null,
paired: null,
trusted: null,
connected: null,
wakeAllowed: null,
};
let inputState = enabled ? 'not-detected' : 'disabled';
let bluetoothError = null;
let inputError = null;
let reconnectDetail = null;
let diagnosticsUpdatedAt = null;
let lastConnectedAt = null;
let tareSamples = [];
let tare = { topRight: 0, bottomRight: 0, topLeft: 0, bottomLeft: 0 };
let stableSamples = [];
@@ -143,6 +156,13 @@ function getState() {
batteryPercent,
lastError,
lastMeasurement,
bluetooth,
inputState,
bluetoothError,
inputError,
reconnectDetail,
diagnosticsUpdatedAt,
lastConnectedAt,
settings: {
minimumWeightKg: settings.minimumWeightKg,
stableDurationMs: settings.stableDurationMs,
@@ -331,6 +351,27 @@ function handleWorkerMessage(message = {}) {
emitStateChange('board-paired');
return;
}
if (message.type === 'diagnostics') {
// Preserve the three hardware layers independently. A valid BlueZ bond, an
// active Bluetooth connection, and a readable evdev device are not
// interchangeable and must never collapse back into a single “sleeping”
// boolean in the browser.
const reportedBluetooth = message.bluetooth || {};
bluetooth = {
available: Boolean(reportedBluetooth.available),
paired: Boolean(reportedBluetooth.paired),
trusted: Boolean(reportedBluetooth.trusted),
connected: Boolean(reportedBluetooth.connected),
wakeAllowed: Boolean(reportedBluetooth.wakeAllowed),
};
inputState = typeof message.inputState === 'string' ? message.inputState : 'unknown';
bluetoothError = message.bluetoothError ? String(message.bluetoothError) : null;
inputError = message.inputError ? String(message.inputError) : null;
reconnectDetail = message.reconnectDetail ? String(message.reconnectDetail) : null;
diagnosticsUpdatedAt = Date.now();
emitStateChange('hardware-diagnostics');
return;
}
if (message.type !== 'status') return;
hardwareState = String(message.state || 'unknown');
@@ -341,6 +382,7 @@ function handleWorkerMessage(message = {}) {
if (message.error) lastError = String(message.error);
else if (hardwareState !== 'commissioning') lastError = null;
if (hardwareState === 'connected') {
lastConnectedAt = Date.now();
if (!connected) {
connected = true;
beginTare();
@@ -61,6 +61,8 @@ constexpr int kFrameIntervalMs = 50;
constexpr int kDeviceScanIntervalMs = 500;
constexpr int kDiscoveryRestartDelayMs = 1000;
constexpr const char* kDiscoveryTimeoutSeconds = "86400";
constexpr int kBluetoothMonitorIntervalMs = 2000;
constexpr int kReconnectAttemptIntervalMs = 3000;
constexpr int kBatteryRefreshMs = 5000;
std::atomic<bool> running{true};
@@ -78,6 +80,8 @@ struct PairingSharedState {
std::optional<BluetoothAddress> active_target;
std::optional<BluetoothAddress> active_pin;
std::optional<std::string> commissioned_address;
std::string input_state = "not-detected";
std::string input_error;
bool commissioning = false;
bool pairing_available = true;
};
@@ -94,6 +98,21 @@ struct CommandResult {
std::string output;
};
struct BluetoothDeviceState {
bool available = false;
bool paired = false;
bool trusted = false;
bool connected = false;
bool wake_allowed = false;
std::string error;
};
struct InputProbe {
std::optional<std::string> path;
std::string state = "not-detected";
std::string error;
};
struct RunningCommand {
pid_t pid = -1;
int output_fd = -1;
@@ -155,6 +174,36 @@ void emit_frame(const BoardReadings& readings, std::optional<int> battery_percen
emit_json(fields.str());
}
void emit_diagnostics(const std::string& address,
const BluetoothDeviceState& bluetooth,
const std::string& input_state,
const std::string& input_error,
const std::string& reconnect_detail) {
// Bluetooth bonding, the current radio link, and Linux evdev readiness are
// separate layers. Report each one explicitly so the server never has to
// infer all hardware failures from the absence of weight frames.
std::ostringstream fields;
fields << "\"type\":\"diagnostics\","
<< "\"address\":\"" << json_escape(address) << "\","
<< "\"bluetooth\":{"
<< "\"available\":" << (bluetooth.available ? "true" : "false") << ","
<< "\"paired\":" << (bluetooth.paired ? "true" : "false") << ","
<< "\"trusted\":" << (bluetooth.trusted ? "true" : "false") << ","
<< "\"connected\":" << (bluetooth.connected ? "true" : "false") << ","
<< "\"wakeAllowed\":" << (bluetooth.wake_allowed ? "true" : "false") << "},"
<< "\"inputState\":\"" << json_escape(input_state) << "\"";
if (!bluetooth.error.empty()) {
fields << ",\"bluetoothError\":\"" << json_escape(bluetooth.error) << "\"";
}
if (!input_error.empty()) {
fields << ",\"inputError\":\"" << json_escape(input_error) << "\"";
}
if (!reconnect_detail.empty()) {
fields << ",\"reconnectDetail\":\"" << json_escape(reconnect_detail) << "\"";
}
emit_json(fields.str());
}
std::optional<int> read_board_battery() {
static uint64_t last_read_at = 0;
static std::optional<int> cached;
@@ -383,6 +432,33 @@ std::string command_error_summary(const std::string& raw, const std::string& fal
return summary.empty() ? fallback : summary;
}
bool command_succeeded(const CommandResult& result) {
if (result.exit_code != 0) return false;
return result.output.find("Failed") == std::string::npos &&
result.output.find("not available") == std::string::npos;
}
bool bluetooth_property_is_yes(const std::string& output, const std::string& property) {
return output.find(property + ": yes") != std::string::npos;
}
BluetoothDeviceState inspect_bluetooth_device(const std::string& address) {
const CommandResult info = run_command({
"bluetoothctl", "--timeout", "3", "info", address});
BluetoothDeviceState state;
state.available = command_succeeded(info) &&
info.output.find("Device " + address) != std::string::npos;
if (!state.available) {
state.error = command_error_summary(info.output, "BlueZ did not return device information");
return state;
}
state.paired = bluetooth_property_is_yes(info.output, "Paired");
state.trusted = bluetooth_property_is_yes(info.output, "Trusted");
state.connected = bluetooth_property_is_yes(info.output, "Connected");
state.wake_allowed = bluetooth_property_is_yes(info.output, "WakeAllowed");
return state;
}
std::optional<BluetoothAddress> find_default_controller() {
const CommandResult controller = run_command({"bluetoothctl", "show"});
std::istringstream lines(controller.output);
@@ -395,26 +471,103 @@ std::optional<BluetoothAddress> find_default_controller() {
return std::nullopt;
}
bool command_succeeded(const CommandResult& result) {
if (result.exit_code != 0) return false;
return result.output.find("Failed") == std::string::npos &&
result.output.find("not available") == std::string::npos;
}
void prepare_known_device(const BluetoothAddress& address) {
run_command({"bluetoothctl", "--timeout", "8", "trust", address.display});
std::string prepare_known_device(const BluetoothAddress& address) {
const CommandResult trust = run_command({
"bluetoothctl", "--timeout", "8", "trust", address.display});
if (!command_succeeded(trust)) {
return "trust failed: " + command_error_summary(trust.output, "BlueZ returned no detail");
}
// WakeAllowed tells current BlueZ releases to accept the board's incoming HID
// connection after its front power button is pressed. Older releases may not
// implement the command; trust + the stored link key still remain effective.
run_command({"bluetoothctl", "--timeout", "8", "wake", address.display, "on"});
const CommandResult wake = run_command({
"bluetoothctl", "--timeout", "8", "wake", address.display, "on"});
if (!command_succeeded(wake)) {
return "wake policy failed: " + command_error_summary(wake.output, "BlueZ returned no detail");
}
return "";
}
void trust_and_connect(const BluetoothAddress& address) {
prepare_known_device(address);
std::string trust_and_connect(const BluetoothAddress& address) {
if (const std::string prepare_error = prepare_known_device(address);
!prepare_error.empty()) {
return prepare_error;
}
// A board remains awake for only a short window after Sync. Connecting the
// HID profile immediately is what teaches it to initiate future connections
// when its front power button is pressed.
run_command({"bluetoothctl", "--timeout", "8", "connect", address.display});
const CommandResult connect = run_command({
"bluetoothctl", "--timeout", "8", "connect", address.display});
if (!command_succeeded(connect)) {
return "initial connection failed: " +
command_error_summary(connect.output, "BlueZ returned no detail");
}
return "";
}
void connection_monitor_loop(PairingSharedState* shared) {
uint64_t last_reconnect_attempt_at = 0;
while (running.load()) {
std::optional<std::string> address;
std::string input_state;
std::string input_error;
{
std::lock_guard<std::mutex> lock(shared->mutex);
address = shared->commissioned_address;
input_state = shared->input_state;
input_error = shared->input_error;
}
if (!address.has_value()) {
std::this_thread::sleep_for(std::chrono::milliseconds(250));
continue;
}
BluetoothDeviceState bluetooth = inspect_bluetooth_device(*address);
std::string reconnect_detail;
const uint64_t now = monotonic_ms();
if (bluetooth.available && !bluetooth.connected &&
now - last_reconnect_attempt_at >= kReconnectAttemptIntervalMs) {
// A bonded Balance Board normally pages its remembered host after the
// front button is pressed, but adapters and BlueZ versions do not handle
// that incoming reconnect consistently. Page the known address while the
// server is waiting so the several-second blue-light wake window is caught
// from either direction without requiring another red-Sync operation.
last_reconnect_attempt_at = now;
const CommandResult reconnect = run_command({
"bluetoothctl", "--timeout", "4", "connect", *address});
// Query again because Connect() may have changed several properties before
// returning. The diagnostics should describe the resulting state, not the
// stale snapshot taken immediately before the attempt.
bluetooth = inspect_bluetooth_device(*address);
if (bluetooth.connected) {
reconnect_detail = "Bluetooth link established; waiting for the Balance Board input device";
} else if (!command_succeeded(reconnect)) {
reconnect_detail = command_error_summary(
reconnect.output, "Bluetooth reconnect attempt did not complete");
} else {
// bluetoothctl's timeout can end a command without a D-Bus error even
// though the device never connected. Trust the resulting Connected
// property rather than presenting process exit status as hardware success.
reconnect_detail = "Reconnect attempt finished without establishing a Bluetooth link";
}
}
{
std::lock_guard<std::mutex> lock(shared->mutex);
// Forget/recommission can complete while bluetoothctl is returning. Never
// publish an old board's result after the selected address has changed.
if (shared->commissioned_address != address) continue;
input_state = shared->input_state;
input_error = shared->input_error;
}
emit_diagnostics(*address, bluetooth, input_state, input_error, reconnect_detail);
for (int elapsed = 0;
elapsed < kBluetoothMonitorIntervalMs && running.load(); elapsed += 100) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
}
void commissioning_loop(PairingSharedState* shared) {
@@ -531,14 +684,14 @@ void commissioning_loop(PairingSharedState* shared) {
continue;
}
trust_and_connect(*address);
const std::string initial_connection_error = trust_and_connect(*address);
{
std::lock_guard<std::mutex> lock(shared->mutex);
shared->commissioned_address = address->display;
shared->commissioning = false;
}
emit_json("\"type\":\"paired\",\"address\":\"" + json_escape(address->display) + "\"");
emit_status("waiting", address->display);
emit_status("waiting", address->display, initial_connection_error);
}
}
@@ -616,27 +769,39 @@ void process_management_events(int fd, PairingSharedState* shared) {
}
}
std::optional<std::string> find_board_input_path() {
InputProbe probe_board_input() {
InputProbe probe;
DIR* directory = opendir("/dev/input");
if (!directory) return std::nullopt;
if (!directory) {
probe.state = "input-directory-unavailable";
probe.error = std::strerror(errno);
return probe;
}
std::optional<std::string> found;
while (dirent* entry = readdir(directory)) {
if (std::strncmp(entry->d_name, "event", 5) != 0) continue;
const std::string path = std::string("/dev/input/") + entry->d_name;
// Read the sysfs name before opening evdev. The name remains readable when
// device permissions are wrong, allowing diagnostics to distinguish “the
// kernel never created it” from “the service user cannot open it.”
std::ifstream name_file(std::string("/sys/class/input/") + entry->d_name + "/device/name");
std::string name;
std::getline(name_file, name);
if (name != kBoardInputName) continue;
const int fd = open(path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC);
if (fd < 0) continue;
std::array<char, 256> name{};
if (ioctl(fd, EVIOCGNAME(name.size()), name.data()) >= 0 &&
std::string(name.data()) == kBoardInputName) {
found = path;
close(fd);
if (fd < 0) {
probe.state = errno == EACCES ? "permission-denied" : "open-failed";
probe.error = std::strerror(errno);
break;
}
close(fd);
probe.path = path;
probe.state = "detected";
break;
}
closedir(directory);
return found;
return probe;
}
void read_initial_axis(int fd, unsigned int axis, int* destination) {
@@ -756,6 +921,16 @@ void stdin_loop(PairingSharedState* shared) {
}
void simulated_loop() {
BluetoothDeviceState simulated_bluetooth;
simulated_bluetooth.available = true;
simulated_bluetooth.paired = true;
simulated_bluetooth.trusted = true;
simulated_bluetooth.connected = true;
simulated_bluetooth.wake_allowed = true;
// Exercise the same diagnostics contract as real hardware so development UI
// builds cannot silently break the status table merely because CI lacks a
// Bluetooth adapter and physical Balance Board.
emit_diagnostics("SIMULATED", simulated_bluetooth, "ready", "", "");
emit_status("waiting", "SIMULATED");
const std::array<BoardReadings, 12> sequence{{
{0, 0, 0, 0}, {0, 0, 0, 0}, {40, 30, 35, 25}, {95, 82, 90, 76},
@@ -818,6 +993,7 @@ int main() {
}
std::thread commission_thread(commissioning_loop, &pairing);
std::thread connection_monitor_thread(connection_monitor_loop, &pairing);
std::thread input_thread(stdin_loop, &pairing);
if (management_fd >= 0 || pairing.commissioned_address.has_value()) {
emit_status(pairing.commissioning ? "commissioning" : "waiting", configured_address);
@@ -833,15 +1009,27 @@ int main() {
if (input_fd < 0 && monotonic_ms() - last_device_scan_at >= kDeviceScanIntervalMs) {
last_device_scan_at = monotonic_ms();
if (auto path = find_board_input_path()) {
input_fd = open_board_input(*path, &readings);
const InputProbe probe = probe_board_input();
{
std::lock_guard<std::mutex> lock(pairing.mutex);
pairing.input_state = probe.state;
pairing.input_error = probe.error;
}
if (probe.path.has_value()) {
input_fd = open_board_input(*probe.path, &readings);
if (input_fd >= 0) {
std::string address;
{
std::lock_guard<std::mutex> lock(pairing.mutex);
address = pairing.commissioned_address.value_or("");
pairing.input_state = "ready";
pairing.input_error.clear();
}
emit_status("connected", address);
} else {
std::lock_guard<std::mutex> lock(pairing.mutex);
pairing.input_state = errno == EACCES ? "permission-denied" : "open-failed";
pairing.input_error = std::strerror(errno);
}
}
}
@@ -853,6 +1041,8 @@ int main() {
{
std::lock_guard<std::mutex> lock(pairing.mutex);
address = pairing.commissioned_address.value_or("");
pairing.input_state = "not-detected";
pairing.input_error = "Balance Board input device closed";
}
emit_status("waiting", address);
}
@@ -864,5 +1054,6 @@ int main() {
if (management_fd >= 0) close(management_fd);
if (input_thread.joinable()) input_thread.detach();
if (commission_thread.joinable()) commission_thread.join();
if (connection_monitor_thread.joinable()) connection_monitor_thread.join();
return 0;
}
@@ -65,9 +65,23 @@ function describePhase(status, frame) {
};
}
if (!status?.connected) {
if (status?.bluetooth?.connected) {
return {
label: 'Bluetooth connected, input unavailable',
instruction: status?.inputError || 'The radio link is active, but Linux has not exposed a readable Balance Board input device.',
className: 'border-red-500/60 bg-red-950/60 text-red-200',
};
}
if (status?.paired && !status?.lastConnectedAt) {
return {
label: 'Paired, not connected',
instruction: 'Press the front power button. The server is actively attempting to connect during the blue-light wake window.',
className: 'border-amber-500/60 bg-amber-950/60 text-amber-200',
};
}
return {
label: 'Sleeping',
instruction: 'Press the boards front power button, then drive onto the station.',
instruction: 'The previously working board is disconnected. Press its front power button, then drive onto the station.',
className: 'border-slate-600 bg-slate-800 text-slate-200',
};
}
@@ -115,6 +129,24 @@ function CornerLoad({ label, value }) {
);
}
function readableHardwareValue(value) {
if (value == null || value === '') return 'Unknown';
return String(value)
.split('-')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function HardwareStatusRow({ label, value, tone = 'text-slate-200' }) {
return (
<div className="flex min-w-0 items-start justify-between gap-2 border-b border-slate-800 py-0.5 last:border-b-0">
<span className="shrink-0 text-slate-500">{label}</span>
<span className={`min-w-0 break-all text-right font-mono ${tone}`}>{value}</span>
</div>
);
}
export default function BalanceBoardPanel() {
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'balanceBoard'));
@@ -154,10 +186,6 @@ function BalanceBoardPanelContent() {
};
}, [socket]);
useEffect(() => {
if (!status?.connected) setFrame(EMPTY_FRAME);
}, [status?.connected]);
const runAction = useCallback(
(action) => {
if (!socket || actionPending) return;
@@ -171,21 +199,33 @@ function BalanceBoardPanelContent() {
[actionPending, socket],
);
const presentation = useMemo(() => describePhase(status, frame), [frame, status]);
const centerX = clamp(frame.center?.x, -1, 1);
const centerY = clamp(frame.center?.y, -1, 1);
// Keep the last received frame in state for ordinary socket updates, but mask
// it synchronously whenever hardware disconnects. Deriving the visible value
// avoids both a stale-weight render and an extra effect-driven state update.
const displayFrame = status?.connected ? frame : EMPTY_FRAME;
const presentation = useMemo(() => describePhase(status, displayFrame), [displayFrame, status]);
const centerX = clamp(displayFrame.center?.x, -1, 1);
const centerY = clamp(displayFrame.center?.y, -1, 1);
const markerStyle = {
left: `${50 + centerX * 42}%`,
top: `${50 + centerY * 42}%`,
};
const battery = Number.isFinite(Number(frame.batteryPercent))
? Number(frame.batteryPercent)
const battery = Number.isFinite(Number(displayFrame.batteryPercent))
? Number(displayFrame.batteryPercent)
: Number.isFinite(Number(status?.batteryPercent))
? Number(status.batteryPercent)
: null;
const displayedWeight = status?.connected
? frame.totalKg
? displayFrame.totalKg
: status?.lastMeasurement?.totalKg || 0;
const bondStatus = status?.bluetooth?.paired
? (status?.bluetooth?.trusted ? 'Paired and trusted' : 'Paired, not trusted')
: (status?.paired ? 'Saved, not confirmed' : 'Not paired');
const linkStatus = status?.bluetooth?.connected ? 'Connected' : 'Disconnected';
const diagnosticsTime = Number(status?.diagnosticsUpdatedAt);
const diagnosticsLabel = Number.isFinite(diagnosticsTime)
? new Date(diagnosticsTime).toLocaleTimeString()
: 'Never';
const actions = (
<div className="flex flex-wrap items-center justify-end gap-0.5">
@@ -214,7 +254,7 @@ function BalanceBoardPanelContent() {
<div className="absolute left-1/2 top-1/2 h-12 w-12 -translate-x-1/2 -translate-y-1/2 rounded-full border border-dashed border-emerald-400/70" />
<div
className={`absolute z-20 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-lg transition-[left,top] duration-100 ${
frame.totalKg >= Number(status?.settings?.minimumWeightKg || 1)
displayFrame.totalKg >= Number(status?.settings?.minimumWeightKg || 1)
? 'border-white bg-emerald-400 shadow-emerald-400/50'
: 'border-slate-400 bg-slate-600'
}`}
@@ -222,10 +262,10 @@ function BalanceBoardPanelContent() {
aria-label="Center of pressure"
/>
<div className="absolute inset-1 grid grid-cols-2 grid-rows-2 gap-1">
<CornerLoad label="Top left" value={frame.corners.topLeft} />
<CornerLoad label="Top right" value={frame.corners.topRight} />
<CornerLoad label="Bottom left" value={frame.corners.bottomLeft} />
<CornerLoad label="Bottom right" value={frame.corners.bottomRight} />
<CornerLoad label="Top left" value={displayFrame.corners.topLeft} />
<CornerLoad label="Top right" value={displayFrame.corners.topRight} />
<CornerLoad label="Bottom left" value={displayFrame.corners.bottomLeft} />
<CornerLoad label="Bottom right" value={displayFrame.corners.bottomRight} />
</div>
</div>
@@ -236,6 +276,42 @@ function BalanceBoardPanelContent() {
<p className="mt-1 text-xs leading-snug text-slate-300">{presentation.instruction}</p>
</div>
<div className="rounded border border-slate-700 bg-slate-950/70 px-1.5 py-1 text-[0.68rem] leading-tight">
{/* These are deliberately literal diagnostics rather than another
synthesized health badge. Operators need to see which exact
layer—bond, radio link, or Linux input—is blocking readings. */}
<HardwareStatusRow label="Address" value={status?.address || 'None'} />
<HardwareStatusRow
label="Bluetooth device"
value={status?.bluetooth?.available ? 'Known to BlueZ' : 'Not available'}
/>
<HardwareStatusRow label="Bluetooth bond" value={bondStatus} />
<HardwareStatusRow
label="Wake reconnect"
value={status?.bluetooth?.wakeAllowed ? 'Allowed' : 'Not allowed'}
tone={status?.bluetooth?.wakeAllowed ? 'text-emerald-300' : 'text-amber-300'}
/>
<HardwareStatusRow
label="Bluetooth link"
value={linkStatus}
tone={status?.bluetooth?.connected ? 'text-emerald-300' : 'text-amber-300'}
/>
<HardwareStatusRow
label="Input device"
value={readableHardwareValue(status?.inputState)}
tone={status?.inputState === 'ready' ? 'text-emerald-300' : 'text-amber-300'}
/>
<HardwareStatusRow label="Worker" value={readableHardwareValue(status?.hardwareState)} />
<HardwareStatusRow label="Status updated" value={diagnosticsLabel} />
{status?.reconnectDetail ? (
<p className="mt-1 break-words border-t border-slate-700 pt-1 text-amber-200">
Last reconnect: {status.reconnectDetail}
</p>
) : null}
{status?.bluetoothError ? <p className="mt-1 break-words text-red-300">Bluetooth: {status.bluetoothError}</p> : null}
{status?.inputError ? <p className="mt-1 break-words text-red-300">Input: {status.inputError}</p> : null}
</div>
{status?.lastMeasurement ? (
<div className="rounded border border-emerald-700/60 bg-emerald-950/30 p-1 text-xs text-emerald-200">
Last captured: <strong>{formatWeight(status.lastMeasurement.totalKg)}</strong>