Short answer: treat event-type discovery as a reconnect-and-backfill problem, then expose the evidence that lets an operator tell a new device event from a missing one. A live IoT control panel should learn types from versioned envelopes and observed traffic, keep an append-only cursor for replay, and show freshness, gap, and decode signals beside the device state.
I start with a deliberately boring question: if a device reconnects after six minutes, can the panel explain what it missed? A list of event names is not enough. The operator needs to know whether temperature.sample is new, renamed, delayed, or simply absent because the gateway was offline.
Model the event before you discover it
An event envelope gives discovery something stable to inspect. Keep the type, schema revision, source, sequence, and server timestamp outside the payload so a panel can classify messages without understanding every device-specific field.
from dataclasses import dataclass from datetime import datetime from typing import Any @dataclass(frozen=True) class EventEnvelope: event_type: str schema: str device_id: str sequence: int observed_at: datetime payload: dict[str, Any] def discovery_key(event: EventEnvelope) -> tuple[str, str]: """A type plus schema revision is a discoverable contract.""" return event.event_type, event.schema The sequence is the important part for recovery. A timestamp can be skewed; a monotonic sequence can reveal a gap. Store the last acknowledged sequence per device and stream. On reconnect, request events after that cursor, apply them in order, and only then mark the device current.
Replay first.
This is also where a healthtech collaborative cursor editor teaches a useful lesson: a cursor update is disposable, but the fact that a user reconnected and received a backfill is not. For an IoT panel, a stale actuator command deserves the same explicit state as a stale cursor. Keep transient state separate from recovery evidence.
What signals explain a newly discovered device event?
Discovery is useful only when the UI can explain its confidence. I put four signals next to the event-type registry:
- first-seen time and last-seen time;
- schema revisions observed for the type;
- sequence gaps and backfill duration;
- decode outcome, including an unknown-field count.
These are observations, not guesses. A type that appears once with a valid envelope but never again should remain visible as rare, rather than being promoted to a permanent device capability. Conversely, a type that arrives every minute but has a growing gap count should trigger an operational investigation even if decoding succeeds.
The panel should preserve raw envelopes for a short, policy-defined retention period. Redaction belongs at ingestion for secrets and personal data; hiding the payload after a failure makes the failure impossible to diagnose. Your mileage may vary on retention because regulated environments may require a shorter window.
Here is a small in-memory registry suitable for a notebook-to-prod prototype. It intentionally records evidence instead of declaring a type supported.
from collections import defaultdict class EventRegistry: def __init__(self) -> None: self.seen: dict[tuple[str, str], dict[str, int]] = defaultdict( lambda: {"count": 0, "gap_count": 0, "decode_errors": 0} ) self.last_sequence: dict[str, int] = {} def observe(self, event: EventEnvelope, decoded: bool) -> None: key = discovery_key(event) stats = self.seen[key] stats["count"] += 1 if not decoded: stats["decode_errors"] += 1 previous = self.last_sequence.get(event.device_id) if previous is not None and event.sequence > previous + 1: stats["gap_count"] += event.sequence - previous - 1 self.last_sequence[event.device_id] = max( event.sequence, self.last_sequence.get(event.device_id, event.sequence) ) A production implementation should persist these counters and attach a correlation id to the reconnect request. The point of the example is the decision boundary: a newly observed key is a candidate event type; it is not an invitation to silently change control logic.
How should reconnect and backfill shape the control-panel design?
Make reconnect a visible state machine. connected, replaying, current, and degraded are more actionable than a green socket icon. During replay, disable commands that depend on an unknown latest state, or require an explicit confirmation. The right choice depends on whether a command is idempotent and whether the device reports an authoritative revision.
The wire protocol should define what “after sequence N” means, how long history is retained, and how an expired cursor is reported. If the cursor is too old, the panel must request a snapshot and then resume from the snapshot revision. Do not pretend that a snapshot is equivalent to replay: it restores state but cannot prove which intermediate events occurred. In a real control room, that distinction changes the operator's next action. Imagine a thermostat that reports sequence 410, disconnects, and returns with sequence 417. A snapshot may show the current setpoint, but only the retained events can tell you whether a manual override, an automatic safety limit, or both happened between 410 and 417. The panel should therefore show “state restored from snapshot; six events unverified” and keep the command audit trail linked to the reconnect correlation id. That extra line is more useful than a reassuring green dot, especially when two operators are watching the same device and one of them is about to send a command.
WebRTC's recommendation is a useful reminder that real-time systems have distinct connection and transport states; model those states in telemetry instead of collapsing them into one boolean. The same discipline applies when your device feed uses a different transport.
from enum import StrEnum class SyncState(StrEnum): CONNECTED = "connected" REPLAYING = "replaying" CURRENT = "current" DEGRADED = "degraded" def next_state(state: SyncState, *, replay_complete: bool, cursor_expired: bool) -> SyncState: if cursor_expired: return SyncState.DEGRADED if state in {SyncState.CONNECTED, SyncState.REPLAYING} and not replay_complete: return SyncState.REPLAYING return SyncState.CURRENT I once treated a reconnect as a transport concern and let the UI jump straight back to “ready.” That made an out-of-order command look successful in a test fixture. The fix was not a fancier socket; it was making backfill completion a prerequisite for the ready state. Three words: replay before control.
Evaluate discovery with failure-shaped tests
An eval harness should inject the failures operators actually see: duplicate sequence numbers, a missing middle event, a schema revision that adds an optional field, a device that reconnects with an expired cursor, and a delayed event that arrives after a snapshot. For each case, assert both the resulting device state and the signal shown to the operator.
Measure detection latency, replay completion latency, gap precision, decode-error rate, and the percentage of commands blocked while state is uncertain. Token cost matters in the AI-assisted parts of a control panel too: summarize event evidence from compact, structured fields, and send raw payloads to a model only when a human asks for diagnosis. That keeps prompt budgets predictable without hiding the evidence needed for an audit.
A practical test record can stay plain JSON:
def assert_recovery(record: dict) -> None: assert record["state"] in {"current", "degraded"} assert record["backfill_completed"] is True or record["state"] == "degraded" assert "gap_count" in record["signals"] Do not optimize for the number of event types discovered. Optimize for correct operator decisions when discovery is incomplete. A smaller registry with honest uncertainty beats a complete-looking list that hides gaps.
Trade-offs and a decision rule
| Choice | Helps | Costs or limits |
|---|---|---|
| Envelope plus sequence cursor | Clear discovery and replay boundaries | Producers must maintain stable sequencing |
| Snapshot on expired cursor | Fast return to a usable state | Intermediate events cannot be reconstructed |
| Raw-envelope retention | Better incident diagnosis | Storage, privacy, and retention work |
| Strict command blocking during replay | Prevents stale-state actions | Operators wait longer during reconnects |
The catch is that this design is not suitable when devices cannot provide any stable ordering or authoritative snapshot. In that case, use an explicitly eventually-consistent view and show its uncertainty; a message broker or database change-feed may be a better fit for the source of truth. Stick with a simpler heartbeat-only panel when operators never issue stateful commands and a missed event has no safety impact.
Before copying the pattern, replay a week of representative traffic through the harness, inspect false gap alerts, and ask an operator to explain each status without opening logs. I'm not sure any single threshold will transfer between fleets; the evidence model will.