Skip to content

Code Generation

You describe the device in YAML and write thin C# partials. At build time a source generator turns both into strongly-typed Things, functions, commands, and events — no reflection, no hand-written boilerplate, everything checked by the compiler.

   You author                    Source generator            Generated for you
 ┌───────────────────┐         ┌──────────────────┐        ┌───────────────────────┐
 │ adapter-           │         │ build-time,      │        │ Things · Functions    │
 │   registration.yaml│  ────►  │ no reflection    │  ────► │ Commands · Events     │
 │ access-model.yaml  │         │                  │        │ Dispatcher · Program  │
 │ C# partials +      │         │                  │        │                       │
 │   handlers         │         │                  │        │                       │
 └───────────────────┘         └──────────────────┘        └───────────────────────┘

What you write vs. what's generated

You write by hand Generated for you
YAML — device types, properties, commands, functions Thing base classes with property validation
C# partials — protocol, event translation Function state machines, typed command and event classes
Command handler methods (matched by signature) Command dispatcher, routing, DI wiring, entry point

You never edit generated files. You extend the generated partial classes with your protocol logic.


What triggers generation

The generator reads your YAML and scans your C# for a few shapes. Each one opts your adapter into a capability — no registration call needed.

The generator detects… …and adds
A class implementing IDeviceConnection Connection function + lifecycle
A static PollStatus(Thing, …, ct) method Status-poll capability
An access-model.yaml file Access synchronization + typed access records
capabilities.time_sync: true in YAML Time-synchronization capability
A [BiometricTemplate] partial class Serialize / Deserialize / hash

This is why adapters have almost no wiring code: presence of the right shape is the registration.


Handlers are matched by signature, not name

Command handlers are discovered by their shape, not an attribute or a magic method name. Get the signature right and the generator wires it into the dispatcher.

// matched — Task<DeviceCommandResult>, command type, CancellationToken last
public static Task<DeviceCommandResult> Lock(DoorThing door, Access.Lock cmd, Protocol p, CancellationToken ct);
public Task<DeviceCommandResult> Open(Access.Open cmd, Protocol p, CancellationToken ct); // self-handler on the Thing

// NOT matched
public void Handle(Access.Lock cmd);                          // wrong return type
public Task Handle(Access.Lock cmd, CancellationToken ct);    // missing DeviceCommandResult
public Task<DeviceCommandResult> Handle(string cmd, CancellationToken ct); // wrong command type

Detection rules:

  • Return type: Task<DeviceCommandResult> or ValueTask<DeviceCommandResult>.
  • The command parameter is any generated command type; a CancellationToken is last.
  • Middle parameters are resolved from DI (protocol, logger, any registered service).
  • Static, separate-instance, or Thing self-handler shapes all work.
  • The method name is irrelevant — the command type in the parameter list is what binds it.

The same signature-based detection drives PollStatus handlers (see patterns/12-status-polling.md) and the canonical command rules (patterns/04-command-handling.md).


YAML → C# naming

Dotted taxonomy ids become nested types; custom functions become a function + interface + state enum. The mapping is mechanical and stable.

pq.command.access.lock        →  Access.Lock
pq.command.output.activate    →  Output.Activate
pq.command.push               →  Push            (top-level, no nesting)

pq.event.access.granted       →  PqEvent.Access.Granted
pq.event.access.door.opened   →  PqEvent.Access.Door.Opened
pq.event.connection.lost      →  PqEvent.Connection.Lost

An event listed under a device type's events: also becomes a named publication method on that Thing, so the router calls the occurrence by name instead of assembling an event. The method is named after the shortest unique tail of the event id, and carries the event's taxonomy parameters:

pq.event.access.door.forced             →  door.DoorForced(timestamp)
pq.event.system.configuration.changed   →  panel.ConfigurationChanged(timestamp, …)

A method of that name you write by hand always wins, so a richer variant stays yours. See reference/generated-code-api.md.

Custom functions declared in your adapter YAML expand into three types:

custom_functions:
  zone_bypass:
    states: [active, inactive]
zone_bypass  →  ZoneBypassFunction · IZoneBypassFunction · ZoneBypassState

Standard functions from the framework taxonomy follow the same shape (door → DoorFunction · IDoorFunction · DoorState). See reference/generated-code-api.md for the full generated surface and how to call it.


Debugging generated code

Normal development never requires reading generated output — you work against the typed API. When something looks wrong, though:

  • Handler not picked up? Compare its signature against the rules above — one wrong parameter and it is silently skipped.
  • Types missing after a YAML edit? The generator caches aggressively — clean and rebuild.
  • Wrong name? Naming comes from YAML id fields; check the dotted path and function keys.

Generated files are read-only. Build output lands in your project's .GeneratedFiles/ folder for inspection only. Never edit it — your changes are overwritten on the next build. Extend the partial class instead.


See Also