Skip to content

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

  1. Operator creates the root device in PQ with connection parameters.
  2. Operator starts the device (it must be running and connected).
  3. Operator runs Import configuration on that root.
  4. Root runs IDeviceImportDiscovery.Discover(...) over the live connection.
  5. Adapter returns the full descendant subtree as AdapterDeviceCollection.
  6. Framework posts that subtree to PQ.
  7. Server reconciles descendants under the existing root in one apply.
  8. 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 default still applies)
  • a typed reference property per deviceRef property — set it to another node as an object reference, not an id
  • a typed List<...> per structural child type
  • AssignIds() and ToModel() from IDiscoveryNode
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.import manually 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:

types:
  Reader:
    identity: [address]
    properties:
      address:
        type: integer

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:

  1. framework verifies the device is running and connection is live
  2. calls Discover(IServiceProvider, CancellationToken) on the root
  3. reconciles discovered subtree with persisted subtree (preserving existing IDs)
  4. posts reconciled AdapterDeviceCollection to /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:

  • Devices contains direct children of the persisted root
  • the root itself is not part of the returned collection
  • each node uses the same AdapterDeviceModel shape as the import payload:
  • Id
  • TypeId
  • Name
  • Properties
  • Children

Returned Payload

Discover(...) returns AdapterDeviceCollection.

Important fields:

  • TypeId
  • Name
  • Properties
  • Children
  • Id

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
  • deviceRef properties 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 deviceRef whose 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.EntryReader Undefined, 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). Use Set / None only 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 TypeId and properties against adapter metadata
  • server creates missing descendants
  • server updates descendants whose device_id is 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:

  1. Running root gets pq.command.config.import command button in UI.
  2. Import on stopped device fails with appropriate message.
  3. Import creates descendants under an empty running root.
  4. Reimport with preserved device_id (via identity matching) updates the same descendants in place.
  5. Reimport without a previously existing node deletes that node.
  6. Invalid TypeId is rejected.
  7. Unsupported property names are rejected.
  8. Discovery failure returns command failure and no partial apply.

Minimal Recipe

  1. Define root properties needed to connect to the device.
  2. Declare identity: [...] on discoverable child types in YAML.
  3. Implement static Discover(TThing, Protocol, CancellationToken) handler on root.
  4. 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.
  5. Build AdapterDeviceCollection with identity properties populated.
  6. Return the full subtree (framework handles ID reconciliation).
  7. Start device in PQ, run import, verify create/update/delete behavior.

See Also