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"]);
Device References (Cross-Links)¶
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):
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:
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