Pattern 14: Device Configuration Import¶
Use When¶
Use this pattern when the physical device can enumerate its own topology and you want PQ to persist that subtree automatically.
Typical examples:
- controllers that can enumerate readers, inputs, outputs, doors, or modules
- devices that expose topology through SDK, REST, XML, or proprietary protocol calls
- devices where the operator should create only the root and let the adapter discover the rest
Do not use this pattern when:
- the device cannot enumerate its own structure
- the subtree must always be created manually in PQ
- you only need runtime status polling, not structural import
What Happens¶
- Operator creates the root device in PQ with connection parameters.
- Operator starts the device (it must be running and connected).
- Operator runs
Import configurationon that root. - Root runs
IDeviceImportDiscovery.Discover(...)over the live connection. - Adapter returns the full descendant subtree as
AdapterDeviceCollection. - Framework posts that subtree to PQ.
- Server reconciles descendants under the existing root in one apply.
- Device continues running; configuration changes become active on next restart.
This is explicit only. There is no heuristic discovery mode.
Adapter Contract¶
Implement a static Discover handler method (similar to command handlers). The source generator detects it and wires up IDeviceImportDiscovery.
Build the subtree with the generated typed discovery nodes, not raw dictionaries. For every non-abstract device type that declares identity:, the generator emits a <TypeId>DiscoveryNode class in the adapter's Discovery namespace. Each node exposes:
- a typed property per declared YAML property (scalars are CLR-typed; optional scalars are nullable and omitted when left unset, so the YAML
defaultstill applies) - a typed reference property per
deviceRefproperty — set it to another node as an object reference, not an id - a typed
List<...>per structural child type AssignIds()andToModel()fromIDiscoveryNode
using Pq.Adapters.Framework;
using Pq.Domain.Api.Adapter;
using YourAdapter.Discovery;
internal sealed partial class Controller : DeviceAdapterBase
{
public static async Task<AdapterDeviceCollection> Discover(
Controller thing,
Protocol protocol,
CancellationToken ct)
{
var topology = await protocol.ReadTopology(ct);
var reader = new ReaderDiscoveryNode { Address = 1, Name = "Reader 1" };
var door = new DoorDiscoveryNode
{
Address = 1,
Name = "Front Door",
EntryReader = reader, // deviceRef wired as an object reference, not an id
};
var collection = new AdapterDeviceCollection();
foreach (var root in new IDiscoveryNode[] { reader, door })
{
root.AssignIds(); // provisional ids, recurses into child lists
collection.Devices.Add(root.ToModel()); // serialise to the import model
}
return collection;
}
}
Assign provisional ids to every root before calling ToModel() on any node that references another (a deviceRef reads the target's assigned id). When one root references a child of another root, emit the owning root first.
Rules:
- implement it only on the root-capable persisted device type
- do not create a second synthetic import root type
- do not declare
pq.command.config.importmanually in YAML - the source generator attaches that command implicitly
- a node is generated only for types that declare
identity:— add it to every discoverable type, or its node class will not exist
Declare which properties form the stable identity in YAML:
The framework then matches discovered nodes to persisted nodes by (TypeId, identity property values) and preserves their IDs automatically. You do not implement matching logic, and you do not read or copy persisted ids yourself — see the reimport rule below.
What The Framework Does¶
When pq.command.config.import arrives on a running root:
- framework verifies the device is running and connection is live
- calls
Discover(IServiceProvider, CancellationToken)on the root - reconciles discovered subtree with persisted subtree (preserving existing IDs)
- posts reconciled
AdapterDeviceCollectionto/api/adapter/device/{rootId}/import
Framework does not give you a separate import context object. Your root already has its persisted properties and normal protocol access.
Persisted Subtree Read¶
IPqServer.GetPersistedSubtree(DeviceId, ct) returns the currently persisted descendants under the root.
Rules:
Devicescontains direct children of the persisted root- the root itself is not part of the returned collection
- each node uses the same
AdapterDeviceModelshape as the import payload: IdTypeIdNamePropertiesChildren
Returned Payload¶
Discover(...) returns AdapterDeviceCollection.
Important fields:
TypeIdNamePropertiesChildrenId
Devices always means direct children of the persisted root.
Reimport Rule¶
With typed discovery nodes you do not copy persisted ids. Always assign fresh provisional ids and let the framework reconcile.
The framework matches discovered nodes to persisted nodes by (TypeId, identity property values) declared via identity:, then:
- a matched node keeps its persisted
device_id - scalar properties are authoritative: the discovered value overwrites the stored one, and a scalar omitted from a re-import is removed
deviceRefproperties are tri-state (preserve if left untouched, overwrite if set, clear if set to.None/empty) and a device name is preserved once an operator has set one — see Reimport Reconciliation Contract below for the full rules- a new node keeps its provisional id as its real id
- any
deviceRefwhose target was matched is remapped from the provisional id to the persisted id, so cross-references survive reconciliation - a persisted descendant absent from the discovered subtree is deleted during full subtree apply, and references to it held by surviving devices are pruned automatically
Because reconciliation is keyed on identity, your discovery must:
- declare
identity:on every discoverable type, choosing one stable scalar vendor identity (address, channel number, vendor object id) and using it consistently - not match or key on display name or list order
- treat a node that changes type as delete + create (its identity key no longer matches)
You do not call GetPersistedSubtree and you do not implement matching yourself for the typed path; both are handled by ImportIdentityReconciler.
Reimport Reconciliation Contract¶
This is the precise behavioural contract for what a re-import does to already-stored data — the question every discovery author eventually asks: "if I run discovery again, what of the operator's hand-configuration survives, and what gets overwritten?"
The mental model: discovery is authoritative by default. The discovered tree is the source of truth — anything the protocol reports overwrites what was stored, and anything the protocol no longer reports is removed. Two things are carved out of that rule because the protocol usually cannot know them (an operator configured them by hand): deviceRef bindings and the device name.
deviceRef properties are tri-state¶
A deviceRef property is any node property whose type is another node type (single) or an array of another node type — it expresses which other device this one is wired to. The classic example is which physical reader serves which door, a binding the panel protocol very often does not expose, so an operator configures it by hand.
On re-import each deviceRef property carries one of three intents, chosen by what the adapter assigns (or doesn't) during discovery:
| Intent | How the adapter expresses it | What reconciliation does | Use for |
|---|---|---|---|
| Undefined (default) | do not touch the property | preserves the stored binding | bindings the protocol cannot determine (operator-wired) |
| Set | assign a concrete node (single) or a list of nodes (array) | overwrites the stored binding with the discovered value | bindings the protocol does expose |
| Cleared | assign <TargetNode>.None (single) or an empty list (array) |
removes the stored binding (authoritative empty) | protocol authoritatively reports "no binding" |
door.EntryReader = discoveredReaderNode; // Set → stored binding overwritten
door.EntryReader = ReaderDiscoveryNode.None; // Cleared → stored binding removed
// leave door.EntryReader untouched // Undefined → stored binding preserved
zone.Partitions = new[] { partitionA, partitionB }; // Set (array)
zone.Partitions = PartitionDiscoveryNode.None; // Cleared (array → authoritative empty)
// leave zone.Partitions untouched // Undefined (array) → preserved
Every generated discovery-node type exposes the static markers Undefined and None for exactly
this purpose. The default value of every deviceRef property is Undefined — so an adapter that
simply never assigns a binding automatically preserves whatever an operator configured. You get
preservation for free; you opt out of it by assigning Set or None.
Worked example. A panel reports doors and readers but not which reader serves which door — the operator wires that up in PQ. Your discovery emits the door and reader nodes but leaves
door.EntryReaderUndefined, so re-running discovery every night never wipes the operator's wiring. Contrast a zone's partition membership, which the protocol does report: emit it as Set so PQ tracks the panel as it changes.
This tri-state applies ONLY to deviceRef properties. For ordinary scalar properties (addresses, numbers, reactions, flags) discovery is fully authoritative — a scalar omitted from a re-import is removed, not preserved. Do not rely on omitting a scalar to keep its stored value; if you want a scalar to persist, emit it on every discovery.
Name is preserve-if-present¶
A device name is treated specially, because operators rename devices to something meaningful and expect that to stick:
- if the stored name is non-empty (an operator, or a prior import, named it), re-discovery will not overwrite it with the freshly discovered name;
- if the stored name is empty, the device adopts the discovered name.
So the adapter may always emit the panel's current name — the framework decides whether to apply it. You never need to read the stored name or branch on it.
Automatic dangling-reference cleanup¶
When any device is removed — whether re-discovery dropped it, or an operator deleted it — the framework automatically clears references to it held by surviving devices: a single deviceRef pointing at the removed device is nulled, and the removed device is dropped from any array deviceRef that contained it.
So the adapter does not need to defensively clear or re-emit references to devices that disappeared. Two rules follow: never point a deviceRef at a device you did not include in the discovered tree (it is dangling by construction), and trust the framework to prune references to deleted devices.
Authoring guidance¶
One rule, and the contract takes care of the rest:
Set only the bindings the protocol actually tells you. Leave everything the protocol cannot determine as
Undefined(untouched). UseSet/Noneonly when the protocol is authoritative.
Concretely:
- deviceRef the protocol exposes → assign it (
Set), or.None/empty when the protocol authoritatively reports empty; - deviceRef the protocol does not expose (operator-wired) → leave it untouched (
Undefined; this is the default — do nothing); - scalars → always emit the current protocol value (omission deletes);
- name → always emit the current protocol name (the framework preserves an operator's rename);
- references to vanished devices → do nothing (the framework prunes them).
Get this right and re-import becomes safe to run repeatedly: it tracks the device faithfully while never trampling the parts an operator configured by hand. For when a type should be discoverable at all, see Modeling Conventions.
Server Apply Semantics¶
Server behavior is intentionally simple:
- root already exists
- adapter posts the full descendant subtree under that root
- server validates
TypeIdand properties against adapter metadata - server creates missing descendants
- server updates descendants whose
device_idis preserved - server deletes descendants that are missing from the posted subtree
So this is a full subtree reconciliation, not upsert-only.
Property Rules¶
Properties must use only properties declared on that TypeId in adapter metadata.
Use normal persisted values:
- strings as strings
- numbers as numbers or numeric strings that serialize cleanly
- booleans as booleans
Server stores them as normal persisted device properties.
Keep it simple:
- scalar persisted values only
- no cross-device reference semantics in this flow unless you already know the target property is just a stored scalar value
Choosing The Protocol Path¶
Default:
- reuse the same protocol/connection path as normal runtime communication
- for TCP request-reply protocols on a shared
ProtocolChannel, wrap a multi-packet topology enumeration in one exclusive session so status polls stay queued and interactive command sends return busy instead of interleaving between discovery packets
Allowed:
- do extra adapter-local steps before import when needed
- capability probe
- alternate login
- topology endpoint
- Bonjour or similar lookup
Do not build a second mini-framework for import.
Testing Checklist¶
Before calling the feature done, test:
- Running root gets
pq.command.config.importcommand button in UI. - Import on stopped device fails with appropriate message.
- Import creates descendants under an empty running root.
- Reimport with preserved
device_id(via identity matching) updates the same descendants in place. - Reimport without a previously existing node deletes that node.
- Invalid
TypeIdis rejected. - Unsupported property names are rejected.
- Discovery failure returns command failure and no partial apply.
Minimal Recipe¶
- Define root properties needed to connect to the device.
- Declare
identity: [...]on discoverable child types in YAML. - Implement static
Discover(TThing, Protocol, CancellationToken)handler on root. - In
Discover(...), read topology over the live protocol; for a shared TCP request-reply channel, run the whole multi-packet enumeration under one exclusive session. - Build
AdapterDeviceCollectionwith identity properties populated. - Return the full subtree (framework handles ID reconciliation).
- Start device in PQ, run import, verify create/update/delete behavior.