Skip to content

feat: per-channel CWOP mute with on-screen reminder (closes #161)#163

Merged
cnighswonger merged 2 commits into
mainfrom
feature/cwop-channel-mute
May 23, 2026
Merged

feat: per-channel CWOP mute with on-screen reminder (closes #161)#163
cnighswonger merged 2 commits into
mainfrom
feature/cwop-channel-mute

Conversation

@kanfei-code-agent
Copy link
Copy Markdown
Contributor

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."

  • BackendAPRSWeatherPacket numeric fields are now Optional[int]; None emits the dotted sentinel of the correct width. CwopUploader reads nine cwop_mute_<channel> keys from station_config and passes None for muted fields in _build_packet. New public GET /api/cwop/mute-status exposes the current mute set without requiring admin auth.
  • Frontend — A new "CWOP — Sensor Reporting" panel in Settings with nine checkboxes, plus a non-dismissable yellow banner in AppShell that surfaces the muted set on every page. Banner polls every 30s and refreshes instantly on a cwop-mute-changed window event dispatched after save.
  • TestsTestMissingValueSentinels (per-field sentinel emission in APRSWeatherPacket) and TestBuildPacketMute (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 AppShell rather than the Dashboard. The issue copy asks for an on-screen reminder; placing it in AppShell satisfies that strictly more than a Dashboard-only banner because the Dashboard renders inside AppShell, 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 — clean
  • cd frontend && npm run build — clean (chunk-size warnings are non-blocking)
  • Manual: toggle a channel in Settings → CWOP — Sensor Reporting; confirm the yellow banner appears within ~1s listing the muted channel.
  • Manual: tail the next CWOP packet (5 min cadence) and verify the muted field shows as dotted in the WX string.
  • Manual: clear the mute and confirm the banner disappears and the next packet returns to normal numeric values.

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown

@vsits-codex-review-agent vsits-codex-review-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread backend/app/api/cwop.py
router = APIRouter(prefix="/cwop", tags=["cwop"])


@router.get("/mute-status")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread frontend/src/hooks/useCwopMuteStatus.ts Outdated
const refresh = () => {
fetchCwopMuteStatus()
.then((res) => {
if (!cancelled) setMuted(res.muted);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Copy Markdown

@vsits-codex-review-agent vsits-codex-review-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked the two previously requested changes on 735c249.

  • backend/app/api/cwop.py: optional_auth plus {"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 than require_admin for 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 stable CWOP_MUTE_CHANNELS order.

Verification I ran locally:

  • cd backend && python3 -m py_compile app/api/cwop.py app/api/dependencies.py app/services/cwop.py
  • cd backend && python3 -m pytest ../tests/backend/ -q -> 401 passed
  • cd frontend && npx tsc --noEmit

Copy link
Copy Markdown
Owner

@cnighswonger cnighswonger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A human was here... 😉

@cnighswonger cnighswonger merged commit 7f016a2 into main May 23, 2026
3 checks passed
@cnighswonger cnighswonger deleted the feature/cwop-channel-mute branch May 23, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: per-channel CWOP mute with on-screen reminder for muted sensors

1 participant