Skip to content

Pattern 4: Command Handling (Typed Commands)

Framework-Generated Command Classes

Framework generates typed command classes from pq-commands.yaml:

"pq.command.access.open" → Access.Open
"pq.command.access.lock" → Access.Lock
"pq.command.output.activate" → Output.Activate
// Commands/DoorCommands.cs
using Pq.Adapters.Framework.Commands;

public static class DoorCommands
{
    /// <summary>
    /// Opens a door - framework detects by signature.
    /// </summary>
    public static async Task<DeviceCommandResult> Open(
        DoorDevice door,        // ← Thing type (first param)
        Access.Open command,    // ← Command type (second param)
        Protocol protocol)      // ← DI services (optional)
    {
        await protocol.OpenDoor(door.Address, default);
        return DeviceCommandResult.Succeeded();
    }

    public static async Task<DeviceCommandResult> Lock(
        DoorDevice door,
        Access.Lock command,
        Protocol protocol,
        ILogger<DoorCommands> logger)  // ← Any DI service
    {
        logger.LogInformation("Locking door {Address}", door.Address);
        await protocol.LockDoor(door.Address, default);
        // device confirmation event will be routed normally and update door state
        // for operator context + device timestamp, use WaitForEvent pattern (see below)
        return DeviceCommandResult.Succeeded();
    }
}

Instance Handler in Thing (Self-Handler)

// In DoorDevice.cs
internal sealed partial class DoorDevice
{
    /// <summary>
    /// Thing handles its own command - no Thing parameter needed.
    /// </summary>
    public async Task<DeviceCommandResult> Open(
        Access.Open command,   // ← No Thing param (this IS the Thing)
        Protocol protocol)
    {
        await protocol.OpenDoor(Address, default);
        return DeviceCommandResult.Succeeded();
    }
}

Separate Handler Class (Instance Methods)

// Commands/DoorCommandHandler.cs
public class DoorCommandHandler
{
    public async Task<DeviceCommandResult> Open(
        DoorDevice door,
        Access.Open command,
        Protocol protocol)
    {
        // Framework creates instance via DI: ActivatorUtilities.CreateInstance
        await protocol.OpenDoor(door.Address, default);
        return DeviceCommandResult.Succeeded();
    }
}

Handler Detection Rules

Framework scans ALL classes for methods matching:

  1. Accessibility: public
  2. Return type: Task<DeviceCommandResult> or ValueTask<DeviceCommandResult>
  3. Parameters:
  4. Static method: (ThingType, CommandType, ...DI services)
  5. Self-handler: (CommandType, ...DI services) + method in Thing class
  6. Instance method: (ThingType, CommandType, ...DI services) + separate class

  7. DI services (automatically injected):

  8. Any registered service (Protocol, ILogger, etc.)
  9. CancellationToken (provided by framework)
  10. IServiceProvider (if needed)

  11. No interface required - pure signature-based detection

  12. Validation: Framework emits warnings if:
  13. Handler exists but command not declared in YAML
  14. Command declared in YAML but no handler found

Command Queuing

// In Protocol.cs
public Task<bool> OpenDoor(uint doorId, CancellationToken cancellationToken)
{
    // Enqueue ensures sequential execution per device
    return _transport.ExecuteCommand(async () =>
    {
        var sdk = _transport.GetSdkClient();
        var result = await sdk.SendCommand(
            _device.Address,
            new OpenDoorCommand(doorId),
            cancellationToken
        );
        return result.Success;
    });
}

Wait for Event Pattern

public async Task<bool> OpenDoorAndWait(uint doorId, CancellationToken cancellationToken)
{
    // start waiting BEFORE sending command
    var waitTask = WaitForEvent(
        evt => evt.Code == 0x2000 && evt.DoorId == doorId,
        timeout: TimeSpan.FromSeconds(5),
        consumeEvent: true
    );

    // send command
    await _transport.ExecuteCommand(async () =>
    {
        var sdk = _transport.GetSdkClient();
        await sdk.SendCommand(_device.Address, new OpenDoorCommand(doorId), cancellationToken);
    });

    // wait for confirmation event
    var evt = await waitTask;
    return evt != null;
}

Use cases:

  • Request/reply protocols
  • Verification of command execution
  • Synchronous SDK calls with async events

Architecture

PQ Server
  ↓ CommandMessage (DeviceId, CommandName, Parameters)
  ↓ NATS: pq.command.{category}.{adapterId}
Adapter (NatsCommandReceiver)
  ↓ CommandChannel (unbounded)
  ↓ ICommandDispatcher.Dispatch()
  ├─ DeviceTreeRegistry.Find(DeviceId) → Thing
  ├─ Factory creates IDeviceCommand instance
  └─ Pattern match → Handler method
      ↓ Protocol.Enqueue() → Sequential execution
      ↓ Device-specific implementation
Device executes command

Declaring Commands in YAML

Commands must be declared for each device type in adapter-registration.yaml:

device_types:
  - type_id: "my-device"
    name: "My Device"
    commands:
      - pq.command.access.open
      - pq.command.security.arm

The source generator discovers commands from both pq-commands.yaml (framework) and adapter-registration.yaml (adapter-specific) and generates factory mappings.

CommandResult Best Practices

  • DeviceCommandResult.Succeeded() or new DeviceCommandResult(CommandResult.Success) — command executed and acknowledged by device.
  • DeviceCommandResult.Failed() or new DeviceCommandResult(CommandResult.Failure) — device error, protocol error, or validation failure. Also returned automatically if handler throws an exception.
  • CommandResult.Timeout — command timed out or was canceled by command timeout.
  • CommandResult.NotFound — returned by dispatcher only (device not found, command not in factory). Handlers should not return this.

The generated dispatcher wraps all handler calls in try-catch. Unhandled exceptions are logged with full context and returned as Failure.

Parameterized Commands

Commands can have properties deserialized from CommandMessage.Parameters:

commands:
  camera:
    preset:
      activate:
        description: "Activate camera preset"
        properties:
          presetId:
            type: integer
            required: true

Handler accesses properties via the typed command:

public async Task<DeviceCommandResult> Handle(
    Camera.Preset.Activate command,
    Protocol protocol)
{
    await protocol.ActivatePreset(this.DeviceId, command.PresetId);
    return DeviceCommandResult.Succeeded();
}

CancellationToken Support

Handlers can accept CancellationToken as a DI parameter:

public async Task<DeviceCommandResult> Handle(
    Access.Open command,
    Protocol protocol,
    CancellationToken cancellationToken)
{
    await protocol.OpenDoorAsync(this.DeviceId, cancellationToken);
    return DeviceCommandResult.Succeeded();
}

Cancellation flow: NatsCommandReceiver creates linked CancellationTokenSource per command → tracked in CommandTracker → external cancellation triggers handler's token → OperationCanceledException caught by dispatcher.

Compiler Warnings

PQ0001: Command handler not found Command declared in YAML but no matching handler method exists. Solution: implement a handler.

PQ0002: Handler without YAML declaration Handler exists but command not declared in YAML. Solution: add command to device type's commands list.

Troubleshooting

Handler Not Called (returns NotFound):

  1. Is the command declared in adapter-registration.yaml for this device type?
  2. Does the handler method signature match requirements?
  3. Check compiler warnings for PQ0001 or PQ0002
  4. Rebuild to regenerate dispatcher

Thing Not Found:

  1. Thing not registered in DeviceTreeRegistry
  2. DeviceId mismatch between command and Thing
  3. Device tree not built during adapter startup