Skip to content

Things

A Thing is any device or logical unit in your adapter's hierarchy.


What is a Thing?

+--------------------------------------------------+
|                     Adapter                       |
|                (DeviceAdapterBase)                |
|                   = root Thing                    |
+--------------------------------------------------+
         |                           |
         v                           v
+------------------+       +------------------+
| Door Controller  |       | Door Controller  |
|     (Thing)      |       |     (Thing)      |
+------------------+       +------------------+
    |         |                    |
    v         v                    v
+------+ +--------+           +--------+
|Reader| |REX Btn |           | Reader |
|(Thing)| |(Thing) |           | (Thing)|
+------+ +--------+           +--------+

Things are:

  • Hierarchical - form parent-child tree
  • Identifiable - each has unique DeviceId
  • Addressable - can receive commands
  • Stateful - have functions that report status

Thing Hierarchy

Physical security systems have natural hierarchy:

Real World                          Thing Model
----------                          -----------

Access Control Panel          -->   DeviceAdapterBase
    |                                    |
    +-- Door 1                -->       Door (Thing)
    |     |                                |
    |     +-- Card Reader     -->         Reader (Thing)
    |     +-- REX Button      -->         RexButton (Thing)
    |     +-- Door Contact    -->         Contact (Thing)
    |
    +-- Door 2                -->       Door (Thing)
          |
          +-- Biometric       -->         BiometricReader (Thing)

Base Class: Thing

All devices inherit from Thing:

public class Thing
{
    // unique identifier
    public Guid DeviceId { get; }

    // position in hierarchy
    public Thing? Parent { get; }
    public Thing[] Children { get; }

    // status aggregation
    public FunctionStatusAggregator StatusAggregator { get; }

    // find adapter (root of tree)
    public DeviceAdapterBase GetAdapter();

    // publish messages (walks up to adapter)
    protected void PublishEvent(EventMessage event);
    protected void PublishStatus(StatusMessage status);
}

DeviceAdapterBase: The Root Thing

Your adapter class extends DeviceAdapterBase:

public class MyAdapter : DeviceAdapterBase
{
    // DeviceAdapterBase is-a Thing
    // It's the root of your Thing tree

    public override void Initialize()
    {
        InternalInit(
            Guid.Parse("..."),        // adapter ID
            "My Adapter",              // name
            SecurityDevicesDomain.Access);  // domain

        // create child things
        var door1 = new Door(this, "door-1");
        var door2 = new Door(this, "door-2");
    }
}

Creating Child Things

public class Door : Thing
{
    private readonly CardReader _reader;
    private readonly RexButton _rex;

    public Door(Thing parent, string id)
        : base(parent, Guid.Parse(id))
    {
        // create child things
        _reader = new CardReader(this);
        _rex = new RexButton(this);
    }
}

Parent-child relationship:

  • Child gets reference to parent in constructor
  • Parent tracks children automatically
  • Messages flow up to adapter (root)

Message Flow Through Tree

When child Thing publishes event:

+------------------+
| Reader           |
| PublishEvent()   |---+
+------------------+   |
                       | walks up tree
+------------------+   |
| Door             |<--+
| (parent)         |---+
+------------------+   |
                       |
+------------------+   |
| Adapter          |<--+
| (root)           |
| Sends to NATS    |-----> PQ System
+------------------+
// in Reader class
public void OnCardRead(string cardNumber)
{
    // walks up to adapter, which sends to NATS
    PublishEvent(new EventMessage(
        eventType: "card.read",
        nodeId: this.DeviceId,  // this reader's ID
        ...));
}

Finding Things

Navigate the tree:

// from child, find adapter
var adapter = thing.GetAdapter();

// from parent, find children
foreach (var child in thing.Children)
{
    Console.WriteLine(child.DeviceId);
}

// find specific child
var door = adapter.Children
    .OfType<Door>()
    .FirstOrDefault(d => d.DeviceId == doorId);

Thing Lifecycle

                 +------------------+
                 |    Initialize    |
                 | (create tree)    |
                 +--------+---------+
                          |
                          v
                 +------------------+
                 |      Start       |
                 | (connect devices)|
                 +--------+---------+
                          |
                          v
                 +------------------+
                 |     Running      |
                 | (handle commands,|
                 |  publish events) |
                 +--------+---------+
                          |
                          v
                 +------------------+
                 |      Stop        |
                 | (disconnect,     |
                 |  cleanup)        |
                 +------------------+

Status Aggregation

Each Thing aggregates status from its functions:

Door (Thing)
  |-- DoorController (Function) --> "locked"
  |-- ContactSensor (Function)  --> "closed"
  |
  Aggregated status: { lock: "locked", contact: "closed" }
public class Door : Thing
{
    private readonly DoorController _doorController;

    public Door(Thing parent, Guid id) : base(parent, id)
    {
        _doorController = new DoorController(this);
    }

    // when function status changes, Thing aggregates
    // and publishes combined status
}

Example: Complete Thing Tree

public class DeviceAdapter : DeviceAdapterBase
{
    private ControllerDevice? _device;

    public override void Initialize()
    {
        InternalInit(
            Guid.Parse("93b45a14-ff31-45e4-9f83-1bddcbcb5d65"),
            "Acme Controller",
            SecurityDevicesDomain.Access);
    }

    protected override async Task Start(CancellationToken ct)
    {
        // create device (child Thing)
        _device = new ControllerDevice(this);

        // create doors (children of device)
        foreach (var doorInfo in await _protocol.GetDoors(ct))
        {
            var door = new DoorDevice(_device, doorInfo.Id);
            // door creates its own children (readers, etc.)
        }

        await _protocol.Connect(ct);
    }
}

Thing vs Physical Device

One Thing doesn't always equal one physical device:

Physical: 1 controller with 4 doors

Model:    Controller (Thing)
              |-- Door 1 (Thing)
              |-- Door 2 (Thing)
              |-- Door 3 (Thing)
              +-- Door 4 (Thing)

Physical: 1 door with 2 readers

Model:    Door (Thing)
              |-- Entry Reader (Thing)
              +-- Exit Reader (Thing)

Model what makes sense for management, not hardware layout.


Access Points

Some Things are "access points" - places where access decisions happen:

device_types:
  - type_id: Door
    access_point: true   # <-- this is an access point
    ...

  - type_id: Reader
    access_point: false  # <-- not an access point (just input device)
    ...

Access points:

  • Receive credential sync
  • Generate access events
  • Are targets for "unlock" commands

Device Type Inheritance

When you have multiple device variants that share most of their implementation (same properties, functions, commands) but differ in defaults or have minor additions, use device type inheritance to avoid code duplication.

The Problem

Without inheritance, panel variants require full duplication:

# BEFORE: Full duplication for each variant
device_types:
  - type_id: "Panel48"
    category: panel
    properties:
      ip_address: { type: string, required: true }
      port: { type: int, default: 10000 }
    functions:
      ConnectionBase:
      Power:
    commands: []

  - type_id: "Panel192"  # 90% identical to Panel48
    category: panel
    properties:
      ip_address: { type: string, required: true }
      port: { type: int, default: 10000 }
    functions:
      ConnectionBase:
      Power:
    commands: []

This means command handlers must be duplicated for each variant.

The Solution: abstract + extends

Define an abstract base type and have variants extend it:

device_types:
  # Abstract base - not instantiable, shared implementation
  - type_id: "PanelBase"
    abstract: true
    category: panel
    properties:
      ip_address:
        type: string
        required: true
        description: "Network module IP address"
      port:
        type: int
        default: 10000
        description: "TCP port"
    functions:
      ConnectionBase:
      Power:
      Tamper:
    commands: []

  # Concrete variant - inherits everything
  - type_id: "Panel48"
    extends: "PanelBase"
    name: "48-Zone Panel"
    description: "48 zones, 4 partitions"
    properties:
      max_zones:
        type: int
        default: 48
        private: true

  # Another variant - can override defaults
  - type_id: "Panel192"
    extends: "PanelBase"
    name: "192-Zone Panel"
    description: "192 zones, 8 partitions"
    properties:
      max_zones:
        type: int
        default: 192
        private: true

Generated C# Classes

The generator creates proper C# inheritance:

// Abstract base class - command handlers go here
public abstract partial class PanelBase : Thing, IConnectionBaseFunction, ...
{
    public static string TypeId => "PanelBase";
    public string IpAddress { get; set; }
    public int Port { get; set; } = 10000;
}

// Concrete variants inherit everything
public partial class Panel48 : PanelBase
{
    public new static string TypeId => "Panel48";
    public int MaxZones { get; set; } = 48;
}

public partial class Panel192 : PanelBase
{
    public new static string TypeId => "Panel192";
    public int MaxZones { get; set; } = 192;
}

Command Handler Inheritance

Command handlers on the base class work for all derived types:

// Handler on PanelBase works for Panel48 and Panel192
public partial class PanelBase
{
    public Task Handle(Security.Arm cmd, CancellationToken ct)
    {
        // This handler is called for any panel variant
        return ArmPartitionAsync(cmd.PartitionId, ct);
    }
}

Inheritance Rules

Aspect Behavior
properties Base + derived (derived wins on conflict)
functions Union (derived can override function defaults)
commands Union
events Union
category Inherited if not specified on derived type
name Required on concrete types
parents Not inherited (each type defines its own)

When to Use Inheritance

Use inheritance when:

  • Multiple device variants share 80%+ of their implementation
  • You want to avoid duplicating command handlers
  • Variants differ mainly by default values or have minor property additions

Don't use inheritance when:

  • Devices are fundamentally different (different protocols, different functions)
  • You need different parent relationships for each variant

Things Should Be Minimal

The framework generates most of a Thing's implementation from YAML. Your Thing class is just the extension point - keep it as small as possible.

Framework Handles

  • all function properties and their state
  • status publishing and aggregation
  • command dispatch
  • device tree registration

Don't Add Local State for Function Data

The framework owns function values. Adding fields to mirror them creates duplication and drift.

// WRONG - duplicating state the framework already manages
public class Panel(Thing parent, Guid id) : Thing(parent, id)
{
    private bool _isArmed;       // framework already tracks this
    private int _zoneCount;      // never needed internally
    private string? _lastUser;   // mirrors a function value

    public void UpdateArmed(bool armed)
    {
        _isArmed = armed;        // half-baked sync
        // ...
    }
}

// RIGHT - no local state for function data
public partial class Panel(Thing parent, Guid id) : Thing(parent, id);

Don't Add Custom Constructors

Generated code expects the standard (Thing parent, Guid id) or (Thing parent, Guid id, ...services) signature. A custom constructor breaks generation or forces awkward workarounds.

// WRONG - custom constructor that reorders or wraps parameters
public class Zone : Thing
{
    public Zone(Thing parent, int zoneNumber)
        : base(parent, ComputeId(zoneNumber))  // magic ID computation hidden here
    {
        ZoneNumber = zoneNumber;
    }

    public int ZoneNumber { get; }
}

// RIGHT - primary constructor, address set via SetAddress()
public partial class Zone(Thing parent, Guid id) : Thing(parent, id);

Empty Partial Class is Often Correct

If you have no helper methods, no IDeviceConnection, and no DI needs, the class body should be empty.

// WRONG - empty body but not partial, or contains unnecessary boilerplate
public class Reader : Thing
{
    public Reader(Thing parent, Guid id) : base(parent, id) { }
}

// RIGHT
public partial class Reader(Thing parent, Guid id) : Thing(parent, id);

When to Extend a Thing

Extend a Thing only for these reasons:

Reason Example
Implement IDeviceConnection adapter root needs Connect/Disconnect
Navigation helpers GetPartition(), GetZone() to resolve siblings
Inject services via primary constructor pass ILogger or protocol object
// RIGHT - IDeviceConnection on the adapter root
public partial class PanelAdapter(ILogger<PanelAdapter> logger) : DeviceAdapterBase, IDeviceConnection
{
    public async Task ConnectAsync(CancellationToken ct) { ... }
    public Task DisconnectAsync() { ... }
}

// RIGHT - navigation helper on a child Thing
public partial class Partition(Thing parent, Guid id) : Thing(parent, id)
{
    /// <summary>Returns all zones that belong to this partition.</summary>
    public IEnumerable<Zone> GetZones() =>
        Parent!.Children.OfType<Zone>().Where(z => z.PartitionId == DeviceId);
}

// RIGHT - DI via primary constructor
public partial class Panel(Thing parent, Guid id, PanelProtocol protocol) : Thing(parent, id)
{
    /// <summary>Sends a raw command to the panel hardware.</summary>
    public Task SendCommandAsync(byte[] payload, CancellationToken ct) =>
        protocol.SendAsync(payload, ct);
}

Common Mistakes

1. Flat Hierarchy

// WRONG - everything at same level
var door1 = new Door(adapter);
var reader1 = new Reader(adapter);  // should be child of door1

// RIGHT - proper hierarchy
var door1 = new Door(adapter);
var reader1 = new Reader(door1);  // reader belongs to door

2. Wrong Parent Reference

// WRONG - storing wrong parent
public class Reader : Thing
{
    public Reader(Door door) : base(door.Parent, ...)  // bug!
    {
    }
}

// RIGHT
public class Reader : Thing
{
    public Reader(Door door) : base(door, ...)  // door is parent
    {
    }
}

3. Not Using DeviceId for Commands

// WRONG - command uses wrong ID
var door = FindDoor(command.Data["doorName"]);  // by name?

// RIGHT - use DeviceId
var door = FindThingById(command.Data["deviceId"]);

Beyond parent-child hierarchy, Things can reference each other using Device References. This is useful when devices have logical relationships that don't follow the physical tree structure.

Single Device Reference

A property that references another Thing in the tree:

device_types:
  - type_id: DoorAlarm
    properties:
      target_door:
        type: Door          # references Door type_id
        required: true
        description: "Door this alarm monitors"

Generated C#:

public partial class DoorAlarm : Thing
{
    // Nullable reference, resolved after tree construction
    public Door? TargetDoor { get; set; }
}

JSON configuration uses device_id (GUID):

{
  "type": "DoorAlarm",
  "device_id": "...",
  "target_door": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

Array Device Reference (Many-to-Many)

For relationships where one Thing belongs to multiple others (e.g., Zone belongs to multiple Partitions):

device_types:
  - type_id: Zone
    properties:
      partitions:
        type: Partition[]      # [] suffix = array of references
        required: false
        description: "Partitions this zone belongs to"

Generated C#:

public partial class Zone : Thing
{
    // Backing list for deferred resolution
    internal readonly List<Partition> PartitionsBuilder = [];

    // Public read-only access
    public IReadOnlyList<Partition> Partitions => PartitionsBuilder;
}

JSON configuration uses array of GUIDs:

{
  "type": "Zone",
  "device_id": "...",
  "partitions": [
    "partition-guid-1",
    "partition-guid-2"
  ]
}

Reverse Navigation (ReferencedBy)

Every Thing tracks which other Things reference it:

// Forward: Zone knows its partitions
zone.Partitions  // IReadOnlyList<Partition>

// Reverse: Partition knows which zones reference it
partition.ReferencedBy  // IReadOnlyList<Thing>

// Typed reverse navigation
var zones = partition.ReferencedBy.OfType<Zone>();

Resolution Timing

Device references are resolved after the entire tree is built:

1. Parse JSON → create all Things with device_id
2. Set Parent/Children relationships
3. Resolve device references (lookup by GUID)
4. Call AddInboundReference for reverse lookup
5. Run deferred validations

This means you cannot access referenced Things during construction - they may not exist yet.

When to Use Device References

Use Case Pattern
Door composed of reader + relay + contact Single DeviceRef per component
Zone belongs to multiple partitions Array DeviceRef Partition[]
Alarm linked to specific door Single DeviceRef Door
Reader paired with exit reader Single DeviceRef Reader

Device Reference vs Parent

Aspect Parent (tree) DeviceRef (cross-link)
Cardinality Single parent Single or array
Navigation Parent / Children Property / ReferencedBy
When resolved During construction After tree complete
Physical meaning Contains References

Next: Functions - Device capabilities