Short answer: for realtime room admission control in an IoT classroom device control panel, admit a device only after server authentication, then keep admission, subscription, and business delivery as separate observability signals.
That rule matters more than the vendor logo. A green socket is transport evidence, not proof that the device may control a projector or that it has caught up on missed commands. For an edtech control panel, the architecture decision is to put admission on the server, keep the fan-out path narrow, and make every recovery transition visible.
This is an architecture decision record for that boundary. The primary choice is not WebSocket versus another wire protocol. It is which component owns permission, which identifiers survive a reconnect, and what delivery claim the system can honestly make.
Decision and invariants
The browser or wall-mounted tablet may request entry, but it cannot grant entry to itself. The application server authenticates the device, maps it to a classroom and role, and authorizes the room before issuing whatever short-lived connection credential the chosen realtime service requires. A teacher console, a student display, and a facilities tablet can share a physical room while receiving different event classes. Keep those distinctions in the server policy.
Three invariants carry most of the design:
- A classroom, device, command, and connection attempt each have stable identifiers. The client retains the last applied command identifier across reconnects so it can reconcile rather than guess.
- Authentication state, room subscription state, and business-event progress are recorded separately. One cannot stand in for another.
- A command becomes complete only at the delivery boundary the product promises. If the product requires device execution, a successful publish or socket write is insufficient; the device must acknowledge the command and duplicate acknowledgements must be harmless.
The third invariant is the uncomfortable one. Realtime providers can move messages, but an edtech product still owns the meaning of "delivered." A fan-out accepted by a service, a message received by a client process, and a projector actually switching input are three different states. Don't merge them into one success counter.
Short version: transport success is not classroom success.
The failure boundary follows from those invariants. The provider owns its documented transport behavior. The application owns device authorization, command identity, acknowledgements, reconciliation, expiry policy, and the decision to deny control when state is ambiguous. This split also keeps compliance reviews legible: an auditor can inspect why a device entered a room without reverse-engineering a stream of unrelated business events.
What observability signals should control realtime room admission?
Use signals that describe state transitions, not a single blended "connected" metric. At minimum, capture an authentication decision, an admission decision, a subscription transition, and business-event progress as separate records. Correlate them with stable room, device, command, and connection-attempt identifiers. Sensitive credential material does not belong in those records.
For the admission path, record the decision outcome and a bounded reason category such as expired credential, unknown device, wrong room assignment, or policy denial. Those categories are application design, not provider error codes. Keep the raw provider response in restricted diagnostics only when policy allows it; dashboards should aggregate your own stable categories so a vendor wording change does not break alerts.
For subscription state, the useful sequence is requested, admitted, subscribed, disconnected, and recovered. A reconnect creates a new connection attempt, but it does not create a new classroom identity. That distinction lets an operator answer two very different questions: "Are tablets churning connections?" and "Are classroom assignments changing?" If one identifier represents both, the dashboard will lie during a Wi-Fi flap.
Business delivery deserves the longest view. Assign each command an application identifier before fan-out, persist the intended audience, and make clients report the highest safely applied command or individual acknowledgements where order is not enough. Then monitor pending acknowledgements by age, duplicates ignored, recovery gaps detected, and commands expired before application. These are not claims about a vendor's native telemetry. They are signals the control-panel application needs if its promise extends beyond transport acceptance. A command can be published once, observed twice after a reconnect, applied once, and acknowledged twice; that is a healthy outcome when command handling is idempotent. Counting raw receives as successful executions would report 200% completion and hide the real state.
One number is especially dangerous: active connections. It is useful for capacity and churn, yet it says nothing by itself about room authorization or command completion. Pair it with admitted subscriptions and outstanding application acknowledgements. Otherwise a disconnected display and an unauthorized display can collapse into the same red line for entirely different reasons.
I'm not sure which exact signal names every provider exposes today; those names and retention windows need to be checked in the selected service's current documentation. The invariant is still firm — authentication, subscription, and business delivery must remain independently observable.
Options at the fan-out boundary
Choose a service only after writing the required delivery boundary. If the requirement is "authorized clients receive live hints and recover from authoritative state," several managed channels can fit. If it is "every physical device executes every control command exactly once," no channel product removes the need for application acknowledgements, idempotent handlers, expiry, and reconciliation.
| Option | Sensible evaluation case | Trade-off to verify before committing |
|---|---|---|
| Ably | A team wants a managed realtime channel product and is prepared to align admission with its token-auth model. | Verify the documented connection recovery, history, ordering, and delivery semantics against the application's acknowledgement boundary. |
| Pusher Channels | A web-focused team already uses its channel and private-channel authorization conventions. | Confirm how reconnects, missed state, and server-authorized subscriptions map to the device recovery design. |
| AWS IoT Core | Devices already use MQTT identities, certificate policy, and topic-oriented control in AWS. | The device and policy model adds operational detail; verify how the browser control panel bridges into that model. |
| Infrai | A team wants a plain REST API with no client SDK to install, plus one key and a consistent interface across backend capabilities. | It is not the default when an established vendor SDK or MQTT policy model is already the system boundary; delivery semantics still need an application-level acknowledgement design. |
The table is deliberately not a scorecard. Provider feature labels do not establish equivalent guarantees, and I would reject any selection based only on a checked "realtime" box. Read the current authentication and recovery documentation, prototype the reconnect sequence, and record the result against the same invariants.
For this classroom panel, the REST-oriented option is attractive when admission stays behind a Python service and the client needs only the resulting realtime credential and room identity. The catch is organizational: if device certificates, MQTT topics, and AWS policy are already the source of truth, stick with AWS IoT Core unless a migration has a concrete operational payoff. Likewise, keep Ably or Pusher when their client conventions are already embedded and their documented recovery model satisfies the written requirement. Replacing a working boundary creates risk without improving delivery guarantees.
Critical path in Python
The smallest useful example is the application-owned state transition around a provider call. It does not pretend that channel admission proves command delivery. The code below is deliberately vendor-neutral: the server can feed it authentication and subscription results from the selected realtime API without coupling the control panel's recovery rules to undocumented response fields.
import json
import os
import time
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from enum import StrEnum
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
API_ORIGIN = "https://" + ".".join(("api", "infrai", "cc"))
CHANNEL_PATH = "/v1/realtime/channel/get/{channel}"
class Phase(StrEnum):
DENIED = "denied"
ADMITTED = "admitted"
SUBSCRIBED = "subscribed"
RECOVERING = "recovering"
@dataclass(frozen=True)
class DeviceSession:
room_id: str
device_id: str
attempt_id: str
phase: Phase
last_applied_command: int
def retry_delay(value: str | None, attempt: int) -> float:
if value:
try:
return max(0.0, float(value))
except ValueError:
retry_at = parsedate_to_datetime(value)
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=timezone.utc)
return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
return float(2**attempt)
def get_channel(channel: str, max_attempts: int = 4) -> dict:
path = CHANNEL_PATH.replace("{channel}", quote(channel, safe=""))
for attempt in range(max_attempts):
request = Request(
API_ORIGIN + path,
method="GET",
headers={
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Accept": "application/json",
},
)
try:
with urlopen(request, timeout=15) as response:
if not 200 <= response.status < 300:
body = response.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Channel lookup failed: {response.status} {body}")
return json.load(response)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code == 429 and attempt + 1 < max_attempts:
time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
continue
raise RuntimeError(f"Channel lookup failed: {error.code} {body}") from error
raise RuntimeError("Channel lookup exhausted its retry budget")
def admit(
room_id: str,
device_id: str,
attempt_id: str,
authenticated: bool,
assigned_room_id: str,
last_applied_command: int = 0,
) -> DeviceSession:
allowed = authenticated and room_id == assigned_room_id
return DeviceSession(
room_id=room_id,
device_id=device_id,
attempt_id=attempt_id,
phase=Phase.ADMITTED if allowed else Phase.DENIED,
last_applied_command=last_applied_command,
)
def mark_subscribed(session: DeviceSession) -> DeviceSession:
if session.phase is not Phase.ADMITTED:
raise ValueError("Only an admitted device may subscribe")
return replace(session, phase=Phase.SUBSCRIBED)
def begin_recovery(session: DeviceSession, new_attempt_id: str) -> DeviceSession:
if session.phase is Phase.DENIED:
raise ValueError("A denied device cannot recover a subscription")
return replace(
session,
attempt_id=new_attempt_id,
phase=Phase.RECOVERING,
)
if __name__ == "__main__":
channel = get_channel("classroom-204")
session = admit(
room_id="classroom-204",
device_id="projector-panel-2",
attempt_id="attempt-801",
authenticated=True,
assigned_room_id="classroom-204",
last_applied_command=1842,
)
session = mark_subscribed(session)
session = begin_recovery(session, new_attempt_id="attempt-802")
print(json.dumps(channel, indent=2, sort_keys=True))
print(session)
Run this code only in the trusted backend that owns room assignments and holds the API key. attempt-802 replaces the connection attempt while classroom-204, projector-panel-2, and command 1842 survive, which is the identity split needed for reconciliation. Emit an observation after each accepted transition. The channel record is input to the server's admission policy, not evidence by itself that the requesting device belongs in the room. Bind the adapter to the current discovery schema and validate the fields it actually consumes.
After admission, write the transition and correlation identifiers before telling the client it may subscribe. On reconnect, retain the stable classroom and device identities, allocate a fresh connection-attempt identifier, and reconcile from the last safely applied command. Expired commands should remain expired. Partial recovery is normal: one display may catch up while another stays pending, and the dashboard should show those states rather than turning the entire room green.
Rejected shortcut and final rule
The rejected design is client-only admission: place a long-lived credential in the panel, let it choose a room name, and treat connection success as authorization. It is compact, but it crosses the trust boundary in the wrong direction and makes credential expiry or reassignment hard to explain. It also couples delivery reporting to a transport event that cannot prove device execution.
There is a valid use case for a lighter path. A public, read-only classroom status display with no sensitive data and an authoritative snapshot endpoint may use live events only as refresh hints. In that design, losing an event is acceptable because every hint triggers a read of current state; admission and per-command acknowledgement can be simpler. It is not suitable for changing door access, projector power, lab equipment settings, or any action where an expired command must never run after reconnect.
The final decision rule is plain: use server-owned room admission, stable identifiers, and separate observability for authentication, subscription, and application delivery. Select Ably, Pusher Channels, AWS IoT Core, Infrai, or another service only after its documented recovery and delivery behavior has been mapped to those boundaries. A provider can simplify fan-out. The classroom application still owns truth.
References
- W3C WebRTC Recommendation: https://www.w3.org/TR/webrtc/
- Ably token authentication: https://ably.com/docs/auth/token
- Pusher Channels authorization: https://pusher.com/docs/channels/server_api/authorizing-users/
- AWS IoT Core protocol support: https://docs.aws.amazon.com/iot/latest/developerguide/protocols.html
Top comments (0)