98dd16b skipped a boot_report if ANY earlier row had the same boot_count.
That is wrong: the firmware's lifetime boot counter gets reset (reflash /
telemetry reset), and the data shows counts 1-4 recurring in July and again
in September. With that rule a real later boot reusing a number would be
dropped forever.
A retained redelivery is always a copy of the device's most recent boot, so
compare only against the latest row (boot_count + reset_reason). A genuine
new boot always differs from it - the counter moves forward or was reset.
Note: the one-off cleanup run on 2026-09-30 used the same wrong
(serial, boot_count) key and deleted some genuine boot rows along with the
redelivery duplicates; see the session notes / heartbeat-based reboot list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The firmware publishes boot_report on system/info and alerts on
system/alerts with retain=true. Every time the backend (re)connects - which
under uvicorn --reload means every backend file save - the broker redelivers
the last retained message and we inserted it again with occurred_at=now().
Result: the Health tab showed fresh PANIC boots and "Device reset due to
fault" alerts for a device that had been up for 4 days.
- insert_boot_event skips the insert when a row with the same
(device_serial, boot_count) already exists. boot_count is the firmware's
lifetime counter, so it uniquely identifies a boot.
- upsert_alert only writes when state/message actually changed and returns
whether it did; the alert-event history row is only added on a change.
A redelivered identical alert no longer bumps updated_at either.
Existing duplicate rows are not touched by this commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both create paths (Users > Add User page and the CreateUserModal used during
device onboarding) now have a Confirm Password field. A mismatch shows an
inline error and blocks the create call, so a typo can't silently become the
user's Firebase Auth password. The confirm value is client-side only and is
never sent to the backend.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_user wrote the profile with .add() (random doc ID). On first login the
FlutterFlow app looks for users/{uid}, doesn't find it, and creates a second,
bare doc - so every console-created user ended up duplicated, and devices
assigned in the console pointed at the doc the app never reads.
Now the profile is written to users/{uid} with created_time set, and the email
is lowercased to match what Firebase Auth stores. If the Firestore write
fails, the just-created Auth account is deleted so no orphan is left.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The amber diagonal slash on offline devices was too loud on the Fleet list.
Offline now renders all arcs unlit in the neutral dim colour. The amber "?"
for online-but-no-rssi (legacy v1 firmware) is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TLS for the phone app went live on the VPS on 2026-09-30:
- Mosquitto got a second, non-published listener on 8083 with
`protocol websockets`; port 1883 stays plain TCP for the boards
(ESP32s can't spare RAM for TLS).
- The mosquitto service joined the external Docker network npm_npmnet so
NPM (NPMplus) can reach mosquitto:8083 by name; it stays on `default`
to reach the Console backend at 172.20.0.1:8000.
- NPM proxy host mqtt.bellsystems.net -> http://mosquitto:8083 terminates
TLS and renews the Let's Encrypt cert. proxy_read/send_timeout 3600s
added so NPM doesn't drop idle MQTT connections after 60s. NPMplus has no
"Websockets Support" toggle (always on).
- Verified end to end: a paho client over wss://mqtt.bellsystems.net:443
(path /mqtt) authenticated via the Console backend and received a
heartbeat.
Chosen over native 8883 because NPM already owns 80/443 and certificate
renewal, so there is no extra cert handling on the host, and 443 also gets
through networks that block 8883.
The doc now gives the app's final transport (wss, 443, /mqtt, never 1883,
keepalive < 3600s), the listener/network/NPM layout, the end-to-end test,
rollback steps, and a new known gap: the backend's port 8000 is published
on 0.0.0.0, so the /mqtt/auth/* endpoints are reachable from the internet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Broker logs after the 2026-09-30 restart show some boards (PV26B02BP01R01,
BSVSPR-26C20B-STD10R-2KCDPH) subscribing to vesper/{serial}/control rather
than control/command. The app ACL only allows publishing to control/command,
so the app can't command those boards until their firmware is updated.
Recorded so nobody widens the ACL by accident.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full mosquitto.conf (2026-09-30) has a single plain listener on 1883,
used by the boards. The phone app sends a Firebase ID token as its MQTT
password, so a TLS listener must be added before app users go live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checked the live broker on 2026-09-30:
- mosquitto.conf sets no files-backend ACL path. A live test (device A
subscribing to device B's topics) was denied while A's own topics were
delivered, so the files backend does not grant-all and every ACL check
reaches the Console. The missing ACL file is therefore harmless.
- Recorded the container/image, config and passwd locations, which lines
already match the new code, and the one change still pending
(auth/acl cache 300s -> 60s before app users go live).
- Added the copy-paste isolation test so it can be re-run after any broker
config change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/mqtt-app-user-auth.md records what future sessions (and whoever builds
the phone app) need and can't get from the code alone:
- app connection contract: username app_<uid>, Firebase ID token as
password, client id prefix app_<uid>_, TLS only, allowed topics per acc,
- serial field (serial_number, legacy device_id) and the device_serials
mirror + every code path that must keep it in sync,
- the uid-field lookup rule and ACL cache invalidation,
- legacy "vesper" password flag and its log line,
- rollout checklist: backfill, go-auth VPS config (required/recommended),
files-ACL check, TLS listener,
- decisions/gaps: FlutterFlow must sync device_serials itself; the
device_users subcollection is intentionally ignored.
CLAUDE.md gets a short section pointing agents at it before they touch
MQTT auth or anything that edits user_list/status.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DeviceUpdate accepts user_list, so a device PUT could add or remove users
without touching their device_serials - leaving the MQTT app ACL stale
(a removed user would keep access; an added user would be denied).
update_device now diffs the old vs new user_list and, in the same atomic
batch as the device write, ArrayUnion/ArrayRemoves the device's serial on
each added/removed user, then invalidates their MQTT ACL cache entries.
Dangling user references are skipped (updating a missing doc would fail
the whole batch). PUTs without user_list take the old single-update path.
Covered by tests/test_device_serials_sync.py (fake Firestore).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First pytest suite in the backend (backend/tests/, run from backend/ with
`python -m pytest tests`). firebase_admin.verify_id_token and Firestore
are mocked, so no network access is needed. 82 cases:
- /user devices: HMAC ok / wrong / other serial's HMAC, kiosk HMAC,
legacy password with flag on/off, legacy rejected for non-device-shaped
usernames and for app_ users, HMAC rejected for app_ users, legacy-login
log rate limiting.
- /user app users: valid token (asserts check_revoked=True), token for a
different uid, revoked, expired, blocked user, unknown uid, empty uid,
and that a denied token never appears in logs.
- /acl app users: acc 1/2/4 allow/deny per topic, unsupported acc values,
wildcards, foreign serial, malformed topics, wrong clientid prefixes
(incl. uid-prefix collision), blocked/unknown users, cache hit +
invalidate, cache expiry.
- /acl devices/kiosk/superuser: unchanged behaviour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shared legacy password is still needed for boards on pre-HMAC
firmware, but it was accepted for any username. Now:
- controlled by MQTT_ALLOW_LEGACY_PASSWORD (config.py, default true;
documented in .env.example) so it can be switched off without a deploy,
- only accepted for device-shaped usernames (uppercase alphanumeric
segments joined by "-", optional "-kiosk"), never for app_ users or
any other shape,
- every successful legacy login is logged at WARNING with the username,
rate-limited to once per username per hour, so the boards still
depending on it are visible before the flag is turned off.
HMAC auth is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /mqtt/auth/acl now handles "app_<uid>" users:
- topic must be exactly vesper/{serial}/<a>/<b> with serial in the user's
device_serials (resolved by the users doc `uid` field),
- publish (acc 2): only control/command,
- subscribe (acc 4) and read/delivery (acc 1): only control/ack,
status/heartbeat, status/playback,
- wildcard topics (+ / #) are denied,
- clientid must start with "app_<uid>_" so one user cannot reuse another
user's client id to kick them off,
- blocked users are denied; anything else (incl. other acc values) is 403.
Lookups go through mqtt/app_users.py's 60s TTL cache so per-message
checks don't hit Firestore every time; assign/unassign/block invalidate it.
Also fixes the acc comment: mosquitto passes 1 = read (delivery),
2 = write (publish), 4 = subscribe - not "1 = subscribe, 3 = both".
Device/kiosk ACL is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The remote FlutterFlow app connects to Mosquitto as "app_<firebase_uid>"
with a Firebase ID token as the password, so no per-user MQTT accounts
need to exist anywhere.
For app_ usernames, POST /mqtt/auth/user now:
- verifies the token with firebase_admin.auth.verify_id_token
(check_revoked=True),
- requires the decoded uid to equal the uid in the username,
- requires a users doc with that `uid` field (queried, not by doc id)
whose status is not "blocked" (same meaning as users.service.block_user).
It returns 200/403 and logs the deny reason - never the token.
app_ usernames never fall through to the HMAC / legacy "vesper" check.
Device and kiosk auth are unchanged. App users are still denied every
topic by the existing ACL until the app ACL lands in the next commit.
Both handlers are now plain `def` so the blocking Firestore / Firebase
calls run in FastAPI's threadpool instead of stalling the event loop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One-off script to populate the new device_serials array for assignments
made before the assign/unassign sync existed. Treats each device's
user_list as the source of truth and sets every user's device_serials to
exactly the matching serials, so re-running it is a no-op.
Dry run by default; --apply writes (batched, <=400 per commit).
It also reports users whose doc ID != uid field (MQTT resolves users by the
uid field), users with no uid, devices with users but no serial, and
user_list entries pointing at non-existent users. user_list entries are
accepted as DocumentReferences, "users/{id}" paths or raw doc IDs, and an
entry that matches a uid field rather than a doc ID is mapped to its doc.
Dry run against current data: 13 devices, 13 users, 11 users to update,
0 doc-ID/uid mismatches, 1 dangling user_list entry
(Cx2Va72sUzDbr1T8Ebbh on BSVSPR-26I047-STD10R-88YFJP).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a `device_serials: [string]` array to Firestore `users` docs so the
MQTT ACL (and get_user_devices) can answer "which boards may this user
reach?" without streaming the entire devices collection.
- The serial is the value used in MQTT topics vesper/{serial}/...: the
device doc's `serial_number` (flashed into NVS, used by the firmware as
its MQTT id), falling back to the legacy `device_id` for old docs.
Centralised in users.service.device_serial_of().
- assign_device / unassign_device now write the device's user_list and the
user's device_serials (ArrayUnion/ArrayRemove) in one atomic batch.
- The device Manage tab endpoints (POST/DELETE /api/devices/{id}/user-list)
also edit user_list, so they get the same batched sync - otherwise the
most common assignment path would silently leave device_serials stale.
- get_user_devices resolves devices via device_serials with chunked
Firestore "in" queries instead of a full collection scan. Requires the
backfill script (next commit) to be run for existing assignments.
- New mqtt/app_users.py: resolves users by the `uid` FIELD (not doc id -
create_user uses .add(), FlutterFlow uses uid as doc id) with a 60s
in-process TTL cache. Assign/unassign, update, block/unblock and delete
invalidate that uid's entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaces the generic "Playback command failed" with the firmware's specific
messages, documents "Already stopped" as SUCCESS, url no longer sticky,
and the stricter field validation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pid, speed, duration, started_at, source, ts on status/playback (and the
same fields on the WebSocket playback INFO event), plus how to handle a
stale retained message and when the epoch fields are 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up on 529e866 after review:
- SignalIndicator: offline now renders a diagonal slash (standard
"no signal" convention) instead of an X. Added a distinct amber "?"
state for devices that are online but whose firmware never reports rssi
at all (legacy v1 heartbeats predate the rssi/state/ok fields — see API
Reference's v2 migration notes) — previously this looked identical to
"not loaded yet". DeviceListCardView's online block now opts into this
by passing isOnline explicitly.
- Copy-to-clipboard for the serial number was invisible: the icon button
had opacity:0 as an inline style, which beats a CSS hover rule at equal
specificity, so the reveal-on-hover code paths never fired. Replaced
with a shared CopyableSerial component (components/shared/) where the
whole serial text is clickable, not just a trailing icon, and the icon
sits at partial opacity at rest instead of fully hidden. Wired into both
DeviceDetail's header and OverviewTab's hero Serial Number field.
- GeneralTab firmware hero: all stat columns are now equal-width via a
grid instead of ad-hoc flex gaps, and backup_version's "Unknown" sentinel
(the device's own placeholder when no second OTA slot has ever been
flashed) is no longer rendered as the literal string "vUnknown".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SignalIndicator now takes an isOnline prop — when a device is offline it
renders a dimmed amber glyph with an X instead of showing the last-known
(possibly full-bars) RSSI reading, which was misleading on the Device List
table view. Card view already special-cased offline devices and is
unaffected.
Also, while working the device details surface:
- DeviceDetail: hover-to-reveal copy button next to the serial number
subtitle in the page header.
- OverviewTab hero: swapped the redundant "Location" field (already shown
in detail on the General tab's map) for live Firmware Version.
- GeneralTab: new full-width, compact Firmware hero row (version, channel,
validation state, boot count, backup slot) sourced from firmware.status.
- ControlTab: max width brought in line with every other tab (2000, was
1400), and added a 4th "Firmware" section with channel selection,
Update Now, custom-URL flash, and commit/rollback controls, wired to the
firmware.status / ota.* / firmware.commit / firmware.rollback commands
documented in the API Reference.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
req_id (added 2026-09-21 in the v2 topic rebuild, F-062):
- New Extras tab documents it fully: envelope-level (sibling of cmd,
not inside contents), always optional, exact echo/reply behavior,
parse-failure edge case, and an MQTT-only gap — WebSocket and HTTP
transports don't actually read or echo it despite CommandBus
supporting it generically at the bus level.
- Every command's Contents section now carries a persistent note
pointing to Extras, instead of duplicating the explanation 40+ times
or only mentioning it in one Transports card.
- Removed the old "req_id Correlation" card from the Transports tab —
superseded by the Extras tab.
Transports tab: dropped the V2/Legacy sub-tab switcher. It only ever
showed a "not documented yet" placeholder for Legacy, and the legacy
topic set belongs with the rest of the migration reference, not
alongside the current transport list.
Legacy Migration tab (renamed from "v1 → v2 Migration"): added a
"Legacy Topic Migration" table ahead of the command migration table,
mapping every MQTT topic from the old pre-rewrite firmware
("Controller - Production FW") to its v2 equivalent — including three
v2 topics (system/alerts, system/info, system/metrics) that have no
legacy predecessor at all. Reconstructed from that firmware's source;
it has been fully replaced, so this is historical reference only.
playback.play: filled in the full contents field set per the current
Player.cpp implementation — segment_duration, pause_duration,
total_duration, and continuous_loop are the legacy (no "mode") path,
while duration is the v2 path read only when mode is present. Added a
warning callout since sending mode alongside the legacy duration
fields doesn't merge behavior — only one path is read, based solely on
whether mode is present. This is intentional: mode is how a v2 caller
opts in, and its absence is how a v1 caller's request still works.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
relay.set_config is a partial update on the firmware side — any bell
index not present in the durations/outputs maps is left completely
untouched. EditBellOutputsModal and OnboardDevice's bell config step
both only sent entries for the currently-active bell count, so
deselecting a bell (lowering the active count) never actually
deactivated its output on the device — it stayed wired to whatever it
was last set to. The only way to disable a bell was to reselect it and
explicitly set it to "Disabled", which isn't obvious.
Both now send all maxOutputs slots on every save, forcing output to 0
for anything beyond the active count, so lowering the active bell
count reaches the firmware the way it visually appears to in the UI.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a Documentation & Commit Policy section and a Golden Rules entry
requiring every change, however small, to be committed with a
descriptive message explaining why — so project history stays a
reliable record of what happened over time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Throwaway HTML prototype for the device Health tab's layout/visual
direction, kept alongside the other planning docs in strategies/.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The ./data/postgres bind mount hit a WSL2/Docker Desktop bug where the
9p/virtiofs bridge reports normal postgres:postgres 0600 ownership but
the kernel still refuses the postgres user's own open() calls for
write — silent on read, fatal on any WAL write, which took the whole
database down after an unclean shutdown. Switches to a Docker-managed
named volume (bellsystems-postgres-data) that lives inside the WSL2
VM's own filesystem instead. The old bind-mounted data is left in
place at ./data/postgres/ as an untouched backup — that folder was
too wedged by the same bug to even rename, so it's abandoned rather
than deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Matches postgres, which already had restart: unless-stopped — the app
containers were the only ones that didn't come back automatically
after a host reboot or Docker Desktop restart.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
WebSerial port ownership was previously held inside StepFlash, which
made it awkward for other steps (StepVerify) to read from the same
port without fighting over exclusive access. useSerialConnection
centralizes port open/close/read/write so the wizard's steps share one
connection lifecycle instead of each managing their own.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed from the previous Reset Stats commit — these are the
request/response schemas for POST /devices/{id}/reset-stats.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New POST /devices/{id}/reset-stats + ResetStatsModal lets staff clear
QA/bench test data accumulated before a device ships to a customer:
Firestore bell/playback counters and Postgres history tables (logs,
heartbeats, commands, boot events, alert events, ping samples,
diagnostics reports, control/reports, and opt-in live alerts). The two
firmware-side resets (telemetry.reset_boot_data, logs.clear) go over
the existing MQTT command/ack flow instead, since they need the device
online and duplicating that round-trip server-side would just be a
second, inferior implementation.
Also includes two incidental cleanups in devices/router.py: audit-log
entries for create/update now use console_name in their label (missed
by the earlier console_name commit), and add/remove-device-user rename
their local Firestore client from `db` to `fs` to stop shadowing the
`db: AsyncSession` dependency param in the same function scope.
AddDeviceUserModal's results list also gets a max-height + scroll so a
long match list doesn't grow the modal off-screen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New guided flow (Bell Cloud > Devices > Onboard) for claiming a
manufactured/flashed device into a customer's fleet in one pass:
looks up the device by serial, finds or creates the customer
(CreateCustomerModal for a quick inline create), finds or creates the
app user account (reuses CreateUserModal from the user-creation work),
and assigns the device — replacing what used to require jumping
between the inventory, CRM, and user-management pages separately.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The inventory list and detail page previously only showed raw
customer_id / user_list references. list_devices now batch-resolves
customer names and user display names/emails (via Firestore get_all(),
avoiding an N+1 round trip per device) and exposes them as
customer_name / users on DeviceInventoryItem. Search now matches
against device name, console name, customer name, and assigned users
in addition to serial/owner/batch.
Also adds hw_types (multi-select board type) and has_users filters to
GET /manufacturing/devices, and carries console_name through for
display. DeviceInventoryDetail is updated to show and use all of this
(customer name, assigned users, new UI components for signal/charts).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- devices: breadcrumb now falls back through console_name -> device_name
-> device_id, matching every other device display label in the console
- melodies/archetypes: breadcrumb was reading a bare d.name field that
doesn't exist on the melody schema (name lives at
d.information.name.<locale>), so these breadcrumbs always showed blank.
Now uses getLocalizedValue like the rest of the melodies UI.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sysadmin/admin-only settings page for configuring how long device log
history is kept before pruning. GET/PUT /api/settings/log-retention,
new LogRetentionSettings page, nav entry, and route.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Surfaces the new device-health telemetry (boot events, ping RTT,
diagnostics reports, alert events) across the console:
- HealthTab: boot/diagnostics timeline, CPU temp and RSSI charts
(LineChart), current alert status, and a Settings sub-tab for
DeviceHealthSettings thresholds
- LogsTab: dedicated log explorer embedded in the Health tab, with
level/source filtering and fmtLogTimestamp for dense timestamp rows
- OverviewTab: a "Latest Device Issue" card showing the most recent
alert event, colour-coded by severity and fading with age, plus a
modal to inspect the surrounding log lines
- DeviceList/DeviceListCardView/DeviceListMapView: fleet list gains a
SignalIndicator-based RSSI display in place of the plain online dot
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New users can now be created with a password, which creates a real
Firebase Auth account (so they can log into the mobile app immediately)
alongside the Firestore profile document. UserCreate is now
UserProfile + password (request-only, never persisted or echoed back);
deleting a user also removes their Auth account.
- backend/users: split UserCreate into UserProfile (persisted shape)
and UserCreate (adds password), wire firebase_auth create/delete
- CreateUserModal: new lightweight modal for creating a user from
other flows (e.g. device onboarding) without leaving the page
- UserForm: adds the password field for new users; also fixes
useToast() being used undestructured (toast.success(...) was being
called on the hook's return value instead of its .toast method)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Lets staff set a friendly name for a device independent of whatever
name the end user gave it in the app. console_name is never shown to
app users and never synced from/to device_name; every display label
across search, equipment/helpdesk name resolution, device search, and
the Manage tab's issue linker now falls back through console_name ->
device_name -> serial rather than device_name alone.
Also includes an incidental one-line fix in devices/service.py: the
nested-struct deep-merge in update_device() was missing the newly
added device_health_settings key.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
subscrStart, warrantyStart, and maintainedOn are written to Firestore as
Timestamps like the other date fields here, but were missing from
_TIMESTAMP_FIELD_NAMES, so they weren't being converted back to ISO
strings on read.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two-tier (warning/critical) threshold config per device — restarts/week,
RSSI floor, free-heap floor, CPU temp ceiling, plus an offline timeout
used for both a client-computed health status icon and the Health tab
chart's gap detection. Purely advisory for now: no server-side
email/push alerting infra exists yet, so email_on_threshold and
push_on_crash_boot are placeholders for that future work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adopts useDeviceCommand across DeviceDetail and its tabs so config
changes (log levels, clock settings, bell outputs, alert thresholds,
backlight, attributes) are sent to the device immediately via
control commands and only persisted to Firestore once the device
acks success, instead of writing Firestore first and hoping the
device eventually picks it up.
- GeneralTab: log-level sliders now call log.set_serial/sd/mqtt
directly and revert on failure; a background log.get_config +
network.info pull reconciles Firestore against the device's actual
state once per mount
- ClockTab, BellsTab, ControlTab, and the Edit* modals: same
live-command-then-persist pattern
- EditLoggingModal is removed — its job (log level editing) moved
inline into GeneralTab's sliders, so a modal round-trip is no
longer needed
- DeviceDetail wires the shared useDeviceCommand connection through
to each tab and adds a Health tab entry
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two efforts that landed together because the v2 topic work extends
tables the health-telemetry effort added days earlier in the same
files/functions, making them impractical to separate cleanly:
Health/diagnostics telemetry (schema, Jul 13-17):
- New Postgres tables: device_alert_events, device_boot_events,
device_ping_samples, device_diagnostics_reports, plus a `source`
column on device_logs to distinguish log origins
- Query/service layer in pg_mqtt.py and database/__init__.py for
inserting and listing this history, plus a "latest metrics" endpoint
combining most-recent diagnostics + ping RTT per device
- mqtt/router.py gains list endpoints for alert/boot/ping/diagnostics
history, consumed by the upcoming Health tab
MQTT v2 topic migration (Sep 21):
- Heartbeat payload flattened per vesper_mqtt_topic_spec_v2.md, adding
rssi/free_heap/state/ok fields
- Command replies move to control/ack, device-initiated events to
control/reports; mqtt/client.py subscribes to the new topic set and
runs a ping_loop (wired up in main.py) for RTT sampling
- mqtt/logger.py and pg_mqtt.py updated to parse and persist the new
payload shape alongside the legacy fields for backwards compatibility
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pulls the send-command-and-await-ack machinery out of DeviceDetail.jsx
into a reusable hook, so other pages (the onboarding wizard, etc.) can
send a device a command and await its control/ack reply the same way,
with a live-updating toast for non-silent commands.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Scrollbar theming was scoped to .app, so anything rendered outside it via
createPortal(..., document.body) — Modal, Select/MultiSelect's floating
menu, DataTable's column-visibility picker — fell back to the browser's
default light-on-dark scrollbar. Extends the same scrollbar-color rules
to .modal, .select-menu, and .dt-col-picker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.header used a lighter 0.30 background tint than .sidebar's 0.40, so
scrolled content underneath showed through almost undimmed even though
backdrop-filter was applied — the two fixed chrome surfaces no longer
read as one consistent glass layer. Aligns the tint and adds a shadow
to give the header separation from content.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Six new design-system components, all documented with live examples in
the StyleGuide as required by CLAUDE.md before any page can use them:
- MultiSelect: checkbox dropdown for filters/tags, built on Select's
trigger/menu styling
- LineChart: telemetry/time-series charting for the upcoming Health tab
- SignalIndicator: signal-strength glyph for device RSSI display
- PressHoldButton: press-and-hold confirmation for destructive actions
- EditableText: inline click-to-edit text with a hover/focus-revealed
pencil affordance
- TimeRangeSelect: preset + custom time-range picker, backed by the new
lib/timeRange.js helper
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>