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
Static Handler Methods (Recommended)¶
// 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:
- Accessibility:
public - Return type:
Task<DeviceCommandResult>orValueTask<DeviceCommandResult> - Parameters:
- Static method:
(ThingType, CommandType, ...DI services) - Self-handler:
(CommandType, ...DI services)+ method in Thing class -
Instance method:
(ThingType, CommandType, ...DI services)+ separate class -
DI services (automatically injected):
- Any registered service (Protocol, ILogger, etc.)
CancellationToken(provided by framework)-
IServiceProvider(if needed) -
No interface required - pure signature-based detection
- Validation: Framework emits warnings if:
- Handler exists but command not declared in YAML
- 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()ornew DeviceCommandResult(CommandResult.Success)— command executed and acknowledged by device.DeviceCommandResult.Failed()ornew 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):
- Is the command declared in
adapter-registration.yamlfor this device type? - Does the handler method signature match requirements?
- Check compiler warnings for PQ0001 or PQ0002
- Rebuild to regenerate dispatcher
Thing Not Found:
- Thing not registered in
DeviceTreeRegistry - DeviceId mismatch between command and Thing
- Device tree not built during adapter startup