Framework Architecture¶
The adapter framework runs your code as a standalone process that bridges a physical device to the PQ platform over the message bus. It owns the boring, error-prone parts — lifecycle, connection retry, message routing, status aggregation — so your code is just protocol logic and event translation. This page is the runtime mental model: the pieces, how they start, and who is responsible for what.
The Thing tree¶
An adapter models hardware as a tree of Things.
DeviceAdapterBase (root)
└─ Controller / NVR / Panel ← the connection root
├─ Door / Channel / Reader ← addressable child Things
├─ Input
└─ Output
DeviceAdapterBaseis the root and the process entry point. It is generated for you from YAML; you rarely touch it.Thingis the base for every node. Each Thing has properties, exposes functions (Door, Reader, Power…), publishes its own events and status, and owns its children.- The framework walks this tree to start and stop everything in order.
Things are addressed by their protocol address (a channel id, door number, etc.) so incoming device events can be routed to the right node — see patterns/07-address-resolution.md.
The base classes you extend¶
| Base class | Role | You implement |
|---|---|---|
DeviceAdapterBase |
Root Thing, process entry point | Rarely — generated from YAML |
Thing |
A device-tree node | Event translation + command handlers in a partial |
ProtocolBase<TEvent> |
Per-device protocol, serialized command queue | Encode/decode, request/reply |
TransportBase |
Connection layer (custom protocols only) | Only for gRPC / serial / SDK processes — TCP/UDP is built in |
Most adapters implement a ProtocolBase subclass and a few Thing partials, and let
the framework provide the transport.
Startup sequence¶
Startup happens in two phases, both driven by the framework:
- Adapter level — the process announces itself on the bus, receives an adapter id, and starts messaging. From this point the platform knows the adapter exists.
- Device level — the framework builds the Thing tree from YAML, starts shared resources (connection pools, SDK processes), then starts each Thing. Connection Things open their link to the device; once connected, the framework marks the tree ready.
Connection loss is handled for you: the framework retries with exponential backoff and
re-drives the device-level start on reconnect. Your Connect must return true only
when the device is fully usable — see patterns/03-connection-management.md.
Runtime message flow¶
Three kinds of messages cross the bus, and the framework wires all three:
Device event → ProtocolBase → EventRouter → matched Thing method → PublishEvent → bus
Command (bus) → dispatcher → your handler method (matched by signature)
Status → StatusBatch → StatusPublisher → bus (aggregated per Thing)
- Events flow up from the device: your protocol parses a frame, the router resolves the owning Thing by address, and the Thing publishes a typed PQ event. Never build a raw event when a function shortcut exists — see patterns/09-event-publishing.md.
- Commands flow down from the platform: the generated dispatcher routes each command to the handler whose signature matches (see code-generation.md).
- Status is declarative: you describe the current state with
StatusBatchand the framework aggregates and publishes it — see patterns/12-status-polling.md.
Capabilities are opt-in¶
Optional behaviours are enabled by implementing an interface. The framework detects it at runtime and schedules the work — you write no wiring.
| Implement… | You get |
|---|---|
IDeviceConnection |
Connection lifecycle + retry |
IPollableStatus |
Periodic status polling on a framework schedule |
ITimeSynchronized |
Periodic device time synchronization |
IAccessSynchronizable |
Differential credential upload (offline access control) |
This mirrors code generation: the presence of the right shape is the registration.
Responsibilities: framework vs. you¶
| The framework provides | You implement |
|---|---|
| Lifecycle, connection retry, reconnect, health metrics | Protocol encode/decode (ProtocolBase) |
| Message-bus integration (events, commands, status) | Event translation (device → PQ events) |
| Command routing and dispatch | Command handler methods (by signature) |
| Status aggregation and publishing | The current-state description via StatusBatch |
| Differential access sync, address allocation, ordering | Transform(...) for access model (if offline access) |
| Local storage for mappings and settings | Custom transport — only for non-TCP protocols |
Memory safety (a hard rule)¶
Framework schedulers and any long-lived code must not hold a direct reference to a Thing. Store only the Thing id and resolve it from the device tree at execution time.
Why: a direct reference pins the Thing in memory. When a Thing is removed or reconnected, the old instance can no longer be garbage-collected, and schedulers keep acting on a stale node. Holding the id and resolving late keeps the tree collectable and always operates on the live Thing.
// Wrong — captures the Thing, leaks it after removal/reconnect
_timer = Schedule(() => channel.Poll());
// Right — capture the id, resolve the live Thing when the work runs
_timer = Schedule(deviceId, id => Resolve(id)?.Poll());
The framework's own schedulers follow this rule; your custom background work must too.
See Also¶
- concepts/code-generation.md — how YAML + your C# become the types described here
- 00-overview.md — the adapter development big picture
- concepts/things.md — the Thing model in depth
- concepts/events.md — event routing and translation
- patterns/03-connection-management.md — connection lifecycle and reconnect
- reference/dependency-injection.md — services available to your handlers