feat: per-channel CWOP mute with on-screen reminder (closes #161)#163
Conversation
Adds a per-channel mute for CWOP/APRS uploads so the operator can take a single sensor out of service (e.g. while it's under repair) without polluting the public CWOP feed and without losing the rest of the station. Muted fields are emitted as the APRS101 "missing value" sentinel (e.g. `t...`, `b.....`) so other stations and aggregators ignore them. Backend - `APRSWeatherPacket`: numeric fields become `Optional[int]`; `None` emits the correctly-widened dotted sentinel per APRS101 ch.12. Wind direction sentinel aligned to `...` (was `000`). - `CwopUploader._build_packet` reads nine `cwop_mute_<channel>` keys from station_config and substitutes `None` for muted channels. Rain DB query is skipped when the corresponding channel is muted. - Muted-but-missing outdoor temperature still emits a packet with `t...`; missing-and-not-muted still short-circuits (no data to ship). - New public `GET /api/cwop/mute-status` returns the list of currently muted channel ids — drives the on-screen banner without exposing the admin config endpoint. Frontend - New "CWOP — Sensor Reporting" panel in Settings with nine checkboxes wired through the existing val/updateField plumbing. - Non-dismissable yellow banner in AppShell, visible on every page, listing the muted channels. Polls /api/cwop/mute-status every 30s and refetches immediately on a `cwop-mute-changed` window event that Settings dispatches after save. Tests - `TestMissingValueSentinels`: one test per field plus an all-None combined assertion. - `TestBuildPacketMute`: parametrised across all nine channels, plus guards for the temperature-missing / temperature-muted distinction and the rain-DB-query short-circuit. - Existing APRS regression (`test_standard_packet`) still passes unchanged. Out of scope: WU/MQTT/other upload destinations — tracked separately in #162. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
The APRS sentinel widths and _build_packet mute logic look correct, but two issues still need attention before merge: the new public mute-status surface leaks sensor-maintenance state on public deployments, and the AppShell polling hook forces avoidable whole-shell rerenders every 30s even when the mute set is unchanged. Backend/frontend verification was otherwise green locally (py_compile, pytest, tsc).
| router = APIRouter(prefix="/cwop", tags=["cwop"]) | ||
|
|
||
|
|
||
| @router.get("/mute-status") |
There was a problem hiding this comment.
This makes the mute set readable without auth specifically so the AppShell banner can render on public pages. On installations that intentionally expose the dashboard publicly, that turns an operator-only maintenance state into machine-readable public config. If the requirement is an operator reminder, I think this should stay behind admin auth (or only render for authenticated sessions) unless we've explicitly decided the mute set is part of the public surface.
| const refresh = () => { | ||
| fetchCwopMuteStatus() | ||
| .then((res) => { | ||
| if (!cancelled) setMuted(res.muted); |
There was a problem hiding this comment.
fetchCwopMuteStatus() returns a fresh array every time, so setMuted(res.muted) schedules a state update on every successful 30s poll even when the contents are unchanged. Because this hook lives in AppShell, that means an avoidable rerender of the whole shell tree on every page in the steady-state no-change case. Please compare prev vs res.muted before calling setMuted (or otherwise preserve identity when the list is unchanged).
- Gate /api/cwop/mute-status behind optional_auth and return an empty list for unauthenticated callers. On installations where the dashboard is exposed publicly, the previous unconditional read leaked operator maintenance state to anonymous visitors. The reminder banner is intended for the operator, who is logged in. - useCwopMuteStatus: preserve the muted-array identity when the poll returns an unchanged list, so AppShell does not re-render the whole tree every 30s in the steady-state no-change case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Re-checked the two previously requested changes on 735c249.
backend/app/api/cwop.py:optional_authplus{"muted": []}for unauthenticated callers closes the public leak without turning the always-mounted AppShell poll into a steady stream of 401s/auth redirects on public pages. In this app that is the better shape thanrequire_adminfor a no-op banner feed.frontend/src/hooks/useCwopMuteStatus.ts: the state updater now preserves the previous array reference when the muted channel list is unchanged, so the 30s poll no longer forces avoidable AppShell re-renders. The shallow positional comparison is correct here because the backend emits channel ids in stableCWOP_MUTE_CHANNELSorder.
Verification I ran locally:
cd backend && python3 -m py_compile app/api/cwop.py app/api/dependencies.py app/services/cwop.pycd backend && python3 -m pytest ../tests/backend/ -q->401 passedcd frontend && npx tsc --noEmit
Summary
Closes #161. Adds a per-channel CWOP/APRS mute so the operator can suppress a single sensor (while it's under repair, miscalibrated, etc.) without dropping the rest of the station from the public feed and without polluting CWOP with bad readings. Muted channels are emitted as the APRS101 "missing value" sentinel (e.g.
t...,b.....) — every well-behaved APRS consumer treats those as "ignore this field."APRSWeatherPacketnumeric fields are nowOptional[int];Noneemits the dotted sentinel of the correct width.CwopUploaderreads ninecwop_mute_<channel>keys fromstation_configand passesNonefor muted fields in_build_packet. New publicGET /api/cwop/mute-statusexposes the current mute set without requiring admin auth.AppShellthat surfaces the muted set on every page. Banner polls every 30s and refreshes instantly on acwop-mute-changedwindow event dispatched after save.TestMissingValueSentinels(per-field sentinel emission inAPRSWeatherPacket) andTestBuildPacketMute(each mute key end-to-end through_build_packet, plus the temp-missing-vs-muted guard). All 401 backend tests pass.Out of scope: WU / MQTT / other upload destinations — tracked separately in #162.
Notable design choice
The reminder banner lives in
AppShellrather than the Dashboard. The issue copy asks for an on-screen reminder; placing it inAppShellsatisfies that strictly more than a Dashboard-only banner because the Dashboard renders insideAppShell, and the operator sees the reminder no matter which page they're on.Test plan
cd backend && python -m pytest ../tests/backend/ -q— green (401 tests)cd frontend && npx tsc --noEmit— cleancd frontend && npm run build— clean (chunk-size warnings are non-blocking)🤖 Generated with Claude Code