Motivation
The authenticated /ws realtime feed now ships a PII-safe stream for every supported public event type. That is a good default for smoke checks, but long-running integrations often need only one slice of the feed: an MFA worker may only need sms.received, a dashboard widget may only need signal.quality, and a PBX status panel may only need call.state/modem.state. Without server-side event filtering, each client receives unrelated sanitized envelopes and must discard them locally, which wastes queue capacity and makes overflow behavior harder to reason about on small Raspberry Pi deployments.
This is a narrow follow-up to the shipped WebSocket feed, not a raw-event or durable-replay feature.
User journey
- An operator starts Callstack with the normal authenticated HTTP server.
- A client connects to
/ws?events=sms.received,sms.delivery_report with its bearer token.
- The server sends the normal
hello envelope, plus the selected event names so the client can verify the subscription.
- The client receives only those event types; unrelated
signal.quality, call, modem, DTMF, and USSD envelopes are not queued for that socket.
- If the query contains an unsupported event name, the handshake fails with a 400-style WebSocket HTTP response before subscribing any handlers.
API / UX sketch
- Keep
/ws unchanged when no query is supplied: subscribe to every name in SUPPORTED_WEBSOCKET_EVENTS.
- Add an optional comma-separated query parameter:
GET /ws?events=sms.received,sms.delivery_report
- Invalid examples should fail closed and mention supported event names without leaking private data:
GET /ws?events=sms.received,raw.at
- The
hello envelope should remain source-derived from the runtime constants and include both supported and selected events, for example:
{
"type": "hello",
"version": 1,
"events": ["sms.received", "sms.delivery_report", "sms.sent", "call.state", "call.ring", "call.caller_id", "call.dtmf", "modem.state", "signal.quality", "ussd.response"],
"selected_events": ["sms.received", "sms.delivery_report"]
}
Technical approach
- Add a small parser near the existing WebSocket constants in
server.py, for example _selected_websocket_event_specs(request).
- Reuse
_WEBSOCKET_EVENT_SPECS / SUPPORTED_WEBSOCKET_EVENTS; do not duplicate the event-name list in tests or docs.
- Normalize query values by trimming whitespace and dropping empty comma segments.
- Reject any unknown event name before
ws.prepare(request) so unauthorized or malformed subscriptions do not attach handlers.
- Subscribe/unsubscribe only the selected event classes for that client.
- Keep the existing
_enqueue_websocket_envelope() bounded-queue behavior and PII-safe serialize_event() envelopes unchanged.
- If several typed events share one public name (
ModemDisconnectedEvent and ModemReconnectedEvent both map to modem.state), selecting that public name should subscribe to all matching typed event classes.
Affected modules and tests
server.py
_WEBSOCKET_EVENT_SPECS
SUPPORTED_WEBSOCKET_EVENTS
websocket_feed() inside create_app()
tests/test_websocket.py
- Add filter happy-path coverage: requested SMS events are delivered, unrelated signal/call events are not delivered.
- Add duplicate/whitespace normalization coverage.
- Add invalid event-name coverage that asserts the connection is rejected and no stale subscription receives later events.
- Keep the existing privacy and overflow tests green.
README.md / ROADMAP.md docs update within the implementation PR: document the optional events= query only after tests derive examples from SUPPORTED_WEBSOCKET_EVENTS or otherwise guard against drift.
Hardware / modem caveats
- No real modem is required; this should be tested entirely with
EventBus + fake modem fixtures.
- This must not add raw AT, raw SMS body, full caller ID, SIM identifier, IMEI/IMSI/ICCID, webhook URL, or API-key exposure.
- This does not change modem probing, URC parsing, delivery-report collection, SMS storage, or command timing.
Acceptance criteria
Verification gates
git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/test_websocket.py -q
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
Non-goals
- Durable replay, cursors, persisted event logs, or dashboard UI.
- Privileged raw event streams.
- Per-sender or per-recipient filters.
- Changing auth/rate-limit policy for WebSocket connections.
- Adding hardware-dependent modem tests.
Motivation
The authenticated
/wsrealtime feed now ships a PII-safe stream for every supported public event type. That is a good default for smoke checks, but long-running integrations often need only one slice of the feed: an MFA worker may only needsms.received, a dashboard widget may only needsignal.quality, and a PBX status panel may only needcall.state/modem.state. Without server-side event filtering, each client receives unrelated sanitized envelopes and must discard them locally, which wastes queue capacity and makes overflow behavior harder to reason about on small Raspberry Pi deployments.This is a narrow follow-up to the shipped WebSocket feed, not a raw-event or durable-replay feature.
User journey
/ws?events=sms.received,sms.delivery_reportwith its bearer token.helloenvelope, plus the selected event names so the client can verify the subscription.signal.quality, call, modem, DTMF, and USSD envelopes are not queued for that socket.API / UX sketch
/wsunchanged when no query is supplied: subscribe to every name inSUPPORTED_WEBSOCKET_EVENTS.helloenvelope should remain source-derived from the runtime constants and include both supported and selected events, for example:{ "type": "hello", "version": 1, "events": ["sms.received", "sms.delivery_report", "sms.sent", "call.state", "call.ring", "call.caller_id", "call.dtmf", "modem.state", "signal.quality", "ussd.response"], "selected_events": ["sms.received", "sms.delivery_report"] }Technical approach
server.py, for example_selected_websocket_event_specs(request)._WEBSOCKET_EVENT_SPECS/SUPPORTED_WEBSOCKET_EVENTS; do not duplicate the event-name list in tests or docs.ws.prepare(request)so unauthorized or malformed subscriptions do not attach handlers._enqueue_websocket_envelope()bounded-queue behavior and PII-safeserialize_event()envelopes unchanged.ModemDisconnectedEventandModemReconnectedEventboth map tomodem.state), selecting that public name should subscribe to all matching typed event classes.Affected modules and tests
server.py_WEBSOCKET_EVENT_SPECSSUPPORTED_WEBSOCKET_EVENTSwebsocket_feed()insidecreate_app()tests/test_websocket.pyREADME.md/ROADMAP.mddocs update within the implementation PR: document the optionalevents=query only after tests derive examples fromSUPPORTED_WEBSOCKET_EVENTSor otherwise guard against drift.Hardware / modem caveats
EventBus+ fake modem fixtures.Acceptance criteria
/wswith noeventsquery preserves current behavior and existing tests pass./ws?events=sms.received,sms.delivery_reportreceives only those event types and does not enqueue unrelated supported events for that client./ws?events=modem.statereceives both disconnect and reconnect state envelopes.selected_events.helloenvelope includesselected_eventswhile retaining the existing supportedeventslist.SUPPORTED_WEBSOCKET_EVENTS.Verification gates
Non-goals