Skip to content

Generated Code API Reference

The PQ Adapters Framework uses source generators to create strongly-typed APIs from YAML definitions. This eliminates manual boilerplate and ensures compile-time safety for events, commands, and function interfaces.

Framework taxonomies:

  • pq-events.yaml (1143 lines) - Complete event taxonomy
  • pq-commands.yaml (489 lines) - Universal command definitions
  • functions.yaml (376 lines) - Standard function capabilities

See Functions Registry for detailed function reference.

Access Synchronization Generated Types

When an adapter contains access-model.yaml, the source generator creates access model records, differ infrastructure, upload batch builders, and an AccessSynchronizationBase class. The adapter implements Transform(...) in a partial class derived from that generated base.

Standard Transform Input

Without person profile declarations, Transform receives framework access input:

protected override AccessModel.AccessModel Transform(PersonAccess[] persons)

PersonAccess contains person identity, credentials, accessible devices, and APB flag. DeviceAccess contains the resolved Thing and the per-device schedule.

Typed Person Profile Input

When access-model.yaml declares person_profiles, the generator creates adapter-specific access input in the adapter's AccessModel namespace:

protected override AccessModel.AccessModel Transform(AccessModel.PersonAccess[] persons)

Generated AccessModel.PersonAccess includes the same person data plus one typed profile property per declared slot type:

public sealed record PersonAccess(
    FrameworkPersonAccess Source,
    Guid PersonId,
    string Name,
    CredentialEntry[] Credentials,
    DeviceAccess[] Devices,
    Guid? ProfileTemplateId,
    VendorPanelUserProfile? UserProfile,
    bool ApbExempt = false);

Generated AccessModel.DeviceAccess contains only device and schedule:

public sealed record DeviceAccess(Thing Device, WeeklyTimeRange[]? Schedule);

The generated mapper converts ProfileData.Fields to the typed profile before Transform is called. Adapter code should use the typed profile property, for example person.UserProfile, and must not parse profile JSON manually.

Generated Profile Records

For each person_profiles[].slot_type, the generator creates a profile record named from the adapter and slot type, for example VendorPanelUserProfile.

Profile field definitions reuse the YAML property model. Defaults become C# property initializers:

person_profiles:
  - slot_type: User
    fields:
      - name: user_level
        type: int
        default: 0
public sealed partial record VendorPanelUserProfile
{
    [JsonPropertyName("user_level")]
    public int UserLevel { get; set; } = 0;
}

Required fields are checked by the generated mapper. Missing or invalid required profile data fails before adapter transformation.

PqEvent Classes

Overview

The framework generates a hierarchical class structure matching the event taxonomy. Each event becomes a nested static class with builder methods.

Example hierarchy:

// YAML: pq.event.access.granted
PqEvent.Access.Granted

// YAML: pq.event.access.door.opened
PqEvent.Access.Door.Opened

// YAML: pq.connection.connected
PqEvent.Connection.Connected

Builder Pattern

All PqEvent classes use fluent builder API:

PqEvent.Access.Granted
    .At(timestamp, thing)             // Required: when & where
    .WithIdentity(personId, credId)   // Optional: who & what
    .WithReason("Card scan")          // Optional: why
    .WithParameter("key", value)      // Optional: custom metadata
    .Build()                          // Returns PqEventInstance

Builder Methods

.At(DeviceTimestamp timestamp, Thing thing)

Required. Sets event timestamp and source Thing ID from the Thing.

var evt = PqEvent.Access.Denied
    .At(DeviceTimestamp.UtcNow, door)
    .Build();

.WithIdentity(string? personId, string? credentialId)

Optional. Sets identity tracking for access control events.

var evt = PqEvent.Access.Granted
    .At(timestamp, reader)
    .WithIdentity(personId: "123", credentialId: "card-456")
    .Build();

Both parameters are nullable - use what's available:

// Only credential known
.WithIdentity(personId: null, credentialId: cardId)

// Only person known
.WithIdentity(personId: userId, credentialId: null)

.WithReason(string reason)

Optional. Human-readable explanation shown in UI and logs.

var evt = PqEvent.Access.Denied
    .At(timestamp, reader)
    .WithReason("Expired credential")
    .Build();

.WithParameter(string key, object? value)

Optional. Attach custom metadata. Can be called multiple times.

var evt = PqEvent.Access.Door.Opened
    .At(timestamp, door)
    .WithParameter("source", "device")
    .WithParameter("battery_voltage", 12.4)
    .WithParameter("signal_strength", 85)
    .Build();

.Build()

Required. Finalizes builder and returns PqEventInstance.

Common Event Examples

IMPORTANT: All PublishEvent examples below are called from inside Thing classes. Protocol CANNOT call PublishEvent on a Thing - see Complete Example for correct pattern.

Access Events

// Inside ReaderThing class - Thing publishes its own event
// Access granted
await PublishEvent(
    PqEvent.Access.Granted
        .At(evt.Timestamp, reader)
        .WithIdentity(personId, credentialId)
        .Build(),
    cancellationToken
);

// Access denied
await PublishEvent(
    PqEvent.Access.Denied
        .At(evt.Timestamp, reader)
        .WithReason("Invalid PIN")
        .Build(),
    cancellationToken
);

// Unknown credential
await PublishEvent(
    PqEvent.Access.Credential.Unknown
        .At(evt.Timestamp, reader)
        .WithParameter("credential_data", rawData)
        .Build(),
    cancellationToken
);

Door Events

// Door opened (position sensor)
await PublishEvent(
    PqEvent.Access.Door.Opened
        .At(evt.Timestamp, door)
        .Build(),
    cancellationToken
);

// Door forced open (alarm)
await PublishEvent(
    PqEvent.Access.Door.Forced
        .At(evt.Timestamp, door)
        .Build(),
    cancellationToken
);

// Door held open
await PublishEvent(
    PqEvent.Access.Door.HeldOpen
        .At(evt.Timestamp, door)
        .WithParameter("duration_seconds", 60)
        .Build(),
    cancellationToken
);

Power Events

// AC power failure
await PublishEvent(
    PqEvent.Energy.Power.Failure
        .At(evt.Timestamp, controller)
        .Build(),
    cancellationToken
);

// Battery low
await PublishEvent(
    PqEvent.Technical.Battery.Low
        .At(evt.Timestamp, device)
        .WithParameter("battery_percentage", batteryLevel)
        .Build(),
    cancellationToken
);

Connection Events

// Device connected
await PublishEvent(
    PqEvent.Connection.Connected
        .At(DeviceTimestamp.UtcNow, controller)
        .WithParameter("isReconnection", isReconnect)
        .Build(),
    cancellationToken
);

// Device disconnected
await PublishEvent(
    PqEvent.Connection.Disconnected
        .At(DeviceTimestamp.UtcNow, controller)
        .WithReason("Network timeout")
        .Build(),
    cancellationToken
);

Unknown / Unresolved Events

Use the thing.Unknown() extension method instead of building events manually. It automatically selects the correct PQ event type based on the UnhandledReason:

// In EventRouter Unhandled handler (preferred):
router.Unhandled(async (owner, e, reason, ct) =>
{
    var ts = DeviceTimestamp.FromUnixSeconds(e.Timestamp);
    await owner.Unknown(ts, $"vendor:0x{e.Code:X4}:{e.SubCode}", reason, cancellationToken: ct);
});

// In manual dispatch default case:
default:
    var ts = DeviceTimestamp.FromDateTime(evt.Timestamp);
    await door.Unknown(ts, $"0x{evt.Code:X4}", UnhandledReason.Unmapped,
        rawData: Convert.ToBase64String(evt.RawBytes), cancellationToken: ct);
    break;

Two PQ events are produced:

  • pq.event.device.unknown -- no route matched (unmapped vendor event)
  • pq.event.device.unresolved -- route matched but Thing not found in device tree (includes ThingType and Address)

Declared Event Methods

Overview

Every event a device type lists under events: in adapter-registration.yaml becomes a named publication method on that Thing. This is how a router or handler reports an occurrence without touching PublishEvent — see PQC232 for the rule this satisfies.

device_types:
  - type_id: "AcmePanel"
    events:
      - pq.event.system.configuration.changed
// generated on AcmePanel
public ValueTask<bool> ConfigurationChanged(
    DeviceTimestamp timestamp,
    string? changeDescription = null,
    Guid? personId = null) =>
    PublishEvent(PqEvent.System.Configuration.Changed.At(timestamp, this)
        .WithParameter("changeDescription", changeDescription)
        .WithParameter("personId", personId)
        .Build());

The router calls the method:

private static ValueTask<bool> Route(AcmePanel panel, DeviceEvent evt) =>
    panel.ConfigurationChanged(Timestamp(evt));

Method Names

The name is built from the fewest trailing segments of the event id — at least two — PascalCased and joined. It grows one segment at a time until it is unique among the events declared on that same type.

Declared on the type Generated method
pq.event.system.configuration.changed ConfigurationChanged
pq.event.access.door.forced DoorForced
pq.event.intrusion.armed.schedule.early (alongside the next row) ArmedScheduleEarly
pq.event.intrusion.disarmed.schedule.early DisarmedScheduleEarly

Some events carry a recommended name in the taxonomy, used where the short derived name would lose its meaning. That name wins: pq.event.system.firmware.update.started is FirmwareUpdateStarted, not UpdateStarted. The recommendation is part of the taxonomy, so it is the same for every adapter — nothing to configure.

Parameters

DeviceTimestamp timestamp comes first. The parameters the taxonomy declares for the event follow — including those it inherits from its ancestors — required ones as plain arguments, optional ones as nullable with a = null default. Required parameters come before optional ones; within each group the order is ancestor before descendant, then alphabetical. Every argument you pass is attached to the published event.

// version is optional on this event, so pass it only when the device reports it
await panel.FirmwareUpdateStarted(timestamp, version: reported.Version);

Supply the parameters the event carries. Dropping a value the device gave you leaves the audit record less useful than the taxonomy intends.

When a Method Is Not Generated

Two cases, both deliberate:

A method of that name is already written by hand. The hand-written one stands. Write a partial when the generated shape cannot express what you need — an extra parameter, a parameter supplied conditionally, a constant the protocol implies:

public partial class AcmePanel
{
    /// <summary>
    /// Publishes a configuration change with the operator who caused it.
    /// </summary>
    public ValueTask<bool> ConfigurationChanged(DeviceTimestamp timestamp, string description, Guid operatorId) =>
        PublishEvent(PqEvent.System.Configuration.Changed.At(timestamp, this)
            .WithParameter("changeDescription", description)
            .WithParameter("personId", operatorId)
            .Build());
}

An action of one of the type's functions already publishes that event. The function action is the Thing's named surface for it and updates function state in the same call, so call the action — door.Opened(timestamp) — and do not declare the event separately.

Inheritance

Methods are generated for the events declared on the type itself. A base type that declares the events gets the methods, and every type extending it inherits them. Declare a shared event once on the base rather than repeating it on each concrete type.

Thing Function Shortcuts

Overview

Functions generate shortcut methods on Thing classes for state changes. These methods automatically:

  1. Update internal function state
  2. Publish appropriate events
  3. Update Thing status for PQ UI

Preferred calling style: call generated function methods directly on the Thing.

await door.Opened(timestamp);
await door.LockedRemote(timestamp);
await door.UnsecuredRemote(timestamp);

// StatusBatch is the exception: the state enum's type selects the function.
batch.Set(DoorState.Secured);

When to Use Shortcuts vs PqEvent

Scenario Use Reason
State change with predefined event Function shortcut Single call updates state + publishes event
Event without state change Declared event method Declare in events:, call the generated method — no PublishEvent in your code
Event whose parameters the declaration cannot express Hand-written partial on the Thing Your method wins over the generated one
State change + custom metadata Both Shortcut for state, then WithParameter() on any separate custom event

The PqEvent builder is what those methods are built from. Reach for it directly only inside the Thing's own class, when writing the partial the declaration cannot cover.

Door Function Methods

Door function generates methods for each action in functions.yaml:

// Strike control (access control via relay)
await door.Secured(timestamp);
await door.SecuredRemote(timestamp);
await door.Unsecured(timestamp);
await door.UnsecuredRemote(timestamp);
await door.UnsecuredByRex(timestamp);

// Deadbolt control (physical lock via motor)
await door.Locked(timestamp);
await door.LockedRemote(timestamp);
await door.Unlocked(timestamp);
await door.UnlockedRemote(timestamp);

// Position events (contact sensor)
await door.Opened(timestamp);
await door.Closed(timestamp);

// Alarm events
await door.ForcedOpen(timestamp);
await door.ForcedOpenCleared(timestamp);
await door.HeldOpenTooLong(timestamp);
await door.HeldOpenCleared(timestamp);

// Operational modes
await door.HoldUnsecured(timestamp);

// Emergency modes
await door.Evacuation(timestamp, identity);
await door.EvacuationCleared(timestamp);
await door.Lockdown(timestamp, identity);
await door.LockdownCleared(timestamp);

Inside Thing class:

protected override async Task HandleDeviceEvent(DoorEvent evt, CancellationToken ct)
{
    switch (evt.Type)
    {
        case DoorEventType.Opened:
            // Direct call, no prefix
            await Opened(evt.Timestamp);
            break;

        case DoorEventType.Closed:
            await Closed(evt.Timestamp);
            break;

        case DoorEventType.ForcedOpen:
            await ForcedOpen(evt.Timestamp);
            break;
    }
}

Reader Function Methods

// Access events (no state change - use PqEvent instead)
// NOT GENERATED - use PqEvent.Access.Granted

// Duress
await reader.DuressCodeEntered(timestamp, identity);
await reader.DuressCleared(timestamp);

// Lockout
await reader.LockoutTriggered(timestamp, failedAttempts);
await reader.LockoutCleared(timestamp);

Power Function Methods

// AC power
await controller.AcFault(timestamp);
await controller.AcRestored(timestamp);

// Battery
await controller.BatteryBackup(timestamp, percentage);
await controller.BatteryFault(timestamp);
await controller.BatteryLow(timestamp, percentage);
await controller.BatteryCritical(timestamp, percentage);
await controller.BatteryNormal(timestamp);

Tamper Function Methods

await device.TamperDetected(timestamp);
await device.TamperCleared(timestamp);

Input/Output Function Methods

// Input (generic monitored point)
await input.Activated(timestamp);
await input.Deactivated(timestamp);
await input.Fault(timestamp);

// Output (generic control point)
await output.Activated(timestamp);
await output.Deactivated(timestamp);

Command Classes

Overview

Commands use hierarchical structure matching YAML naming:

YAML:

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

Generated classes:

namespace Pq.Adapters.Framework.Commands;

public sealed record Access
{
    public sealed record Lock : IPqCommand { /* ... */ }
    public sealed record Unlock : IPqCommand { /* ... */ }
    public sealed record Synchronize : IPqCommand { /* ... */ }
}

public sealed record Output
{
    public sealed record Activate : IPqCommand { /* ... */ }
    public sealed record Deactivate : IPqCommand { /* ... */ }
}

public sealed record Push : IPqCommand { /* ... */ }

Command Handler Signature

Framework auto-discovers handlers via method signature - no interface required:

public class DoorThing : ThingBase
{
    // Signature-based detection:
    // 1. Static or instance method
    // 2. Returns Task or ValueTask
    // 3. Parameters: (CommandType command, CancellationToken ct)

    public async Task HandleLock(Access.Lock command, CancellationToken ct)
    {
        // command.ThingId - target Thing
        // command.UserId - who issued command
        // command.Timestamp - when issued

        await SendLockCommand(ct);
    }

    public async Task HandleUnlock(Access.Unlock command, CancellationToken ct)
    {
        await SendUnlockCommand(ct);
    }
}

Command Properties

All commands include base properties:

public interface IPqCommand
{
    string ThingId { get; }           // Target Thing
    string? UserId { get; }           // Issuing user
    DateTimeOffset Timestamp { get; } // Command timestamp
}

Commands with parameters add additional properties:

// pq.command.camera.preset.activate
public sealed record Camera
{
    public sealed record Preset
    {
        public sealed record Activate : IPqCommand
        {
            public string ThingId { get; init; }
            public string? UserId { get; init; }
            public DateTimeOffset Timestamp { get; init; }

            // From YAML properties
            public int PresetNumber { get; init; } // range: [1, 256]
        }
    }
}

Handler Examples

// Simple command
public async Task HandlePush(Push command, CancellationToken ct)
{
    await SendMomentaryPulse(ct);
}

// Command with parameters
public async Task HandlePreset(Camera.Preset.Activate command, CancellationToken ct)
{
    await SendPresetCommand(command.PresetNumber, ct);
}

// Command with validation
public async Task HandleOpen(Access.Open.Permanent command, CancellationToken ct)
{
    if (!SupportsExtendedAccess)
    {
        Logger.LogWarning("Device does not support permanent open mode");
        return;
    }

    await SendPermanentOpenCommand(ct);
}

Function Interfaces

Overview

Framework generates interfaces for each function, allowing type-safe casting and property access:

public interface IDoorFunction
{
    DoorState CurrentState { get; }

    // Properties from functions.yaml
    bool HasLockSensor { get; }
    bool HasPositionSensor { get; }
    bool HasRexSensor { get; }
    int DoorlongopenTimeoutSeconds { get; }
    // ... all Door properties
}

public interface IReaderFunction
{
    ReaderState CurrentState { get; }
    // Reader has no properties in base definition
}

public interface IPowerFunction
{
    PowerState CurrentState { get; }
    // Power has no properties in base definition
}

Using Interfaces

Cast Thing functions to interfaces when you need type-safe property access:

public class DoorThing : ThingBase
{
    private readonly IDoorFunction _doorFunction;

    public DoorThing(/* ... */)
    {
        _doorFunction = (IDoorFunction)Door; // Cast function to interface
    }

    private async Task CheckDoorState(CancellationToken ct)
    {
        if (_doorFunction.CurrentState == DoorState.SecuredClosed)
        {
            // Door is in normal secured state
        }

        if (_doorFunction.HasPositionSensor)
        {
            // Device reports door position via contact sensor
        }

        int timeout = _doorFunction.DoorlongopenTimeoutSeconds;
    }
}

State Enums

Framework generates enums for function states:

public enum DoorState
{
    // Deadbolt
    Locked,

    // Strike + position combined
    SecuredClosed,
    SecuredOpen,
    UnsecuredClosed,
    UnsecuredOpen,

    // Extended modes
    HeldUnsecured,

    // Alarms
    ForcedOpen,
    DoorLongOpen,

    // Operational

    // Emergency
    Lockdown,
    Evacuation
}

public enum ReaderState
{
    Normal,
    Lockout,
    Duress
}

public enum PowerState
{
    MainsPowered,
    BatteryBackup,
    LowBattery,
    CriticalBattery,
    Charging,
    PowerFault,
    BatteryFault
}

When to Use Interfaces

Scenario Use Example
State change from device Shortcut methods await door.Opened(timestamp)
Read current state Interface cast if (_door.CurrentState == DoorState.Locked)
Read properties Interface cast int timeout = _door.DoorlongopenTimeoutSeconds
Conditional logic Interface cast if (_door.HasPositionSensor) { ... }

Auto-Discovery Mechanism

Commands

Framework scans for handler methods matching signature:

// Detection criteria:
// 1. Method accessibility: public
// 2. Static method, Thing self-handler, or separate instance handler
// 3. Return type: Task<DeviceCommandResult> or ValueTask<DeviceCommandResult>
// 4. Parameters:
//    static/separate handler: (ThingType thing, TCommand command, ...DI services)
//    Thing self-handler: (TCommand command, ...DI services)

// ✅ Valid signatures
public static Task<DeviceCommandResult> Lock(DoorThing door, Access.Lock cmd, Protocol protocol, CancellationToken ct);
public ValueTask<DeviceCommandResult> Activate(OutputThing output, Output.Activate cmd, Protocol protocol);
public Task<DeviceCommandResult> Open(Access.Open cmd, Protocol protocol); // inside DoorThing

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

Naming convention: Handler method name is irrelevant - detection is purely signature-based.

See Pattern 4: Command Handling for the canonical command handler rules.

Functions

Functions auto-register when attached to Thing:

public class DoorThing : ThingBase
{
    // Framework detects these properties by type
    public DoorFunction Door { get; }           // Detected by name & type
    public PowerFunction Power { get; }         // Detected by name & type
    public TamperFunction Tamper { get; }       // Detected by name & type

    public DoorThing(/* ... */)
    {
        // Functions must be initialized in constructor
        Door = new DoorFunction(this, /* config */);
        Power = new PowerFunction(this, /* config */);
        Tamper = new TamperFunction(this, /* config */);
    }
}

Complete Example

using Pq.Adapters.Framework;
using Pq.Adapters.Framework.Commands;
using Pq.Adapters.Framework.Events;
using Pq.Adapters.Framework.Functions;

namespace MyAdapter;

public class DoorThing : ThingBase
{
    private readonly IDoorFunction _doorFunc;

    public DoorFunction Door { get; }
    public PowerFunction Power { get; }

    public DoorThing(string thingId, IServiceProvider services)
        : base(thingId, services)
    {
        Door = new DoorFunction(this, new DoorConfig
        {
            HasPositionSensor = true,
            HasLockSensor = false,
            DoorlongopenTimeoutSeconds = 30
        });

        Power = new PowerFunction(this, new PowerConfig());

        _doorFunc = (IDoorFunction)Door;
    }

    // === DEVICE EVENT TRANSLATION ===

    protected override async Task HandleDeviceEvent(byte[] rawData, CancellationToken ct)
    {
        var evt = ParseDeviceEvent(rawData);

        switch (evt.Type)
        {
            case DeviceEventType.DoorOpened:
                // Function shortcut - updates state + publishes event
                await Opened(evt.Timestamp);
                break;

            case DeviceEventType.DoorClosed:
                await Closed(evt.Timestamp);
                break;

            case DeviceEventType.AccessGranted:
                // PqEvent builder - no state change
                await PublishEvent(
                    PqEvent.Access.Granted
                        .At(evt.Timestamp, this)
                        .WithIdentity(evt.PersonId, evt.CredentialId)
                        .Build(),
                    ct
                );
                break;

            case DeviceEventType.AccessDenied:
                await PublishEvent(
                    PqEvent.Access.Denied
                        .At(evt.Timestamp, this)
                        .WithReason(evt.DenyReason)
                        .Build(),
                    ct
                );
                break;

            case DeviceEventType.PowerLoss:
                await AcFault(evt.Timestamp);
                break;

            default:
                // Catch-all for unmapped events
                var ts = DeviceTimestamp.FromDateTime(evt.Timestamp);
                await this.Unknown(ts, evt.RawType, UnhandledReason.Unmapped,
                    rawData: Convert.ToBase64String(rawData), cancellationToken: ct);
                break;
        }
    }

    // === COMMAND HANDLERS ===

    public async Task HandleLock(Access.Lock command, CancellationToken ct)
    {
        await SendToDevice(new LockCommand(), ct);

        // Framework calls Door.Secured() after successful send
        await SecuredRemote(DeviceTimestamp.UtcNow);
    }

    public async Task HandleUnlock(Access.Unlock command, CancellationToken ct)
    {
        await SendToDevice(new UnlockCommand(), ct);
        await UnsecuredRemote(DeviceTimestamp.UtcNow);
    }

    public async Task HandleOpen(Access.Open command, CancellationToken ct)
    {
        await SendToDevice(new MomentaryOpenCommand(), ct);
        await UnsecuredRemote(DeviceTimestamp.UtcNow);

        // Auto-relock after timeout
        await Task.Delay(
            TimeSpan.FromSeconds(_doorFunc.AccessGrantTimeoutSeconds),
            ct
        );
        await Secured(DeviceTimestamp.UtcNow);
    }
}

See Also