Skip to main content

Device Lifecycle

:::info Status Implemented. Source: docs/device-management-module-architecture.md §5a–§5d; fastapi-backend/core/device_registry.py (transition matrix matches the doc exactly, verified against source as of this writing). :::

Transmitters and stream players walk the same lifecycle — no shortcut for either kind. Every purchased unit walks it once, on its own; a second unit an owner buys starts back at PENDING however many they already have. Nothing a member does afterward sends a unit back down this path.

States

¹ unblock restores ACTIVE only if inventory and assignment still satisfy the in-service constraint; otherwise it lands on VERIFIED.

Blank cells in the transition matrix mean 409 Conflictnever a silent no-op; an already-ACTIVE unit that quietly "succeeds" on a bad transition would tell an operator the unit was moved when it wasn't.

Transition guards

From → ToGuard
PENDING → VERIFIEDinventory_id NOT NULL; serial + MAC match inventory (Identity); superadmin only. Issues device_secret
VERIFIED → ACTIVEan open assignment must already exist; inventory status ∈ (ALLOCATED, DEPLOYED); stream players additionally require an open device_ownership row
ACTIVE ⇄ INACTIVEsuperadmin; reversible
any non-terminal → BLOCKEDreason required
any non-terminal → LOSTreason required; credentials invalidated on entry
BLOCKED/LOST → REVOKEDreason required; terminal
ACTIVE/INACTIVE → RETIREDinventory → SCRAPPED; assignment force-closed; terminal

activate needs an open assignment to already exist — it cannot create one. A VERIFIED unit with no assignment cannot become ACTIVE; the owner (or Super Admin) must bind first, then activate. This guard fires once, at activation — never again — which is why ACTIVE + UNBOUND is a perfectly normal state, reachable the instant the owner unbinds afterward:

VERIFIED + UNBOUND ──activate──► refused, no open assignment
VERIFIED + BOUND ──activate──► ACTIVE + BOUND ──owner unbinds──► ACTIVE + UNBOUND

POST /devices/{id}/approve performs verify + activate in one transaction — the common path for an already-sold, already-bound unit.

BLOCKED, LOST, REVOKED all invalidate credentials immediately, in one transaction: clear device_secret_hash + broker credential → publish deauthorize → clear retained config → drop broker session → revoke outstanding jti → invalidate the authorization cache. LOST differs from REVOKED only in being reversible, not in speed — a stolen unit powered on is still holding valid credentials until this runs.

Two independent axes — lifecycle vs. binding

lifecycle_status (this page) and binding_state (Mosque Binding, always derived, never stored) are separate axes with separate writers:

#Invariant
L1Exactly one combination plays: ACTIVE ∧ BOUND
L2A binding transition never writes lifecycle_status
L3A lifecycle transition never writes memberships, and never closes an assignment — except RETIRED/REVOKED/LOST, which force-close it in the same transaction
L4binding_state is derived, never stored
L5An open assignment may exist under any lifecycle state, including PENDING
L6A terminal transition must close the open assignment in the same transaction that changes lifecycle_status
L7Bluetooth is a function of neither axis

core/device_registry.py owns every lifecycle write; core/device_binding.py owns every binding write. A function touching both is a bug by definition — this split is what makes L2/L3 enforceable rather than aspirational.

The full state × binding matrix

Any combination not in this table is invalid, and the API must refuse to create it:

lifecyclebindingAzaanMeans
PENDINGanyClaimed, awaiting Super Admin
VERIFIEDUNBOUND/BOUNDVerified in stock, or assigned but not yet activated
ACTIVEBOUNDThe only playing combination
ACTIVEMEMBERSHIP_LAPSEDAuthorized unit, owner left the mosque
ACTIVEUNBOUNDAuthorized unit between mosques
INACTIVE/BLOCKED/LOSTanyMaintenance pause / credentials invalidated
RETIRED/REVOKEDUNBOUND onlyTerminal; binding force-closed on entry

device_inventory.status (IN_STOCK, ALLOCATED, DEPLOYED, IN_REPAIR, LOST, SCRAPPED) tracks the physical asset, moving for different reasons than the device row above:

  • ALLOCATED/DEPLOYED is a precondition for the device to become ACTIVE — not something the device transition triggers itself; it happens at claim.
  • LOST/SCRAPPED cascade to the device row — the asset going away takes the device with it (device forced to LOST/RETIRED, credentials cleared, assignment force-closed, all in one database trigger).
  • IN_REPAIR/IN_STOCK do not cascade — refused while a device is still in service; deactivate or retire the device first.

Full inventory operations: Device Management module.

Broker access follows lifecycle, but the ACL never changes shape

A device's MQTT topic subtree ACL is static for the unit's life — what varies is whether authentication itself succeeds:

lifecycleAuthenticates?Effect
PENDING / VERIFIEDConnects, reports status, receives config without stream_url
ACTIVEFull operation
INACTIVEConnected and visible; no play published to it
BLOCKED/LOST/REVOKED/RETIRED❌ (device_secret_hash cleared)CONNECT refused; any live session kicked

A claimed-but-unapproved unit is deliberately allowed onto the broker — that's what makes approval instant. It simply can't stream: no stream_url in its config, no token would ever be issued for it. Full detail: MQTT Protocol.