Skip to content

Commands

A command is a request from PQ system asking your adapter to do something.


What is a Command?

graph LR
    PQ["PQ System (user clicks unlock)"] -->|"Unlock door 5"| Adapter["Your Adapter (executes on device)"]

Commands are:

  • Requests - PQ asks, your adapter does
  • Future tense - "please do this"
  • Your responsibility - you write code to handle them

Available Commands

Commands are defined in pq-commands.yaml. Common ones:

Command What PQ wants
pq.command.access.open Unlock/open access point momentarily
pq.command.access.lock Lock access point
pq.command.access.unlock Unlock access point (stay unlocked)
pq.command.access.synchronize Sync credentials to device
pq.command.intrusion.arm Arm intrusion zone
pq.command.intrusion.disarm Disarm intrusion zone

Your adapter declares which commands it supports in adapter-registration.yaml:

device_types:
  - type_id: "MyDoor"
    commands:
      - pq.command.access.open
      - pq.command.access.lock

Writing a Command Handler

When PQ sends a command, framework calls your handler. You write a method with specific signature.

The Signature

public Task<DeviceCommandResult> MethodName(
    YourThingType thing,      // required: the device to act on
    CommandType command,      // required: the command
    ...)                      // optional: anything you need from DI
  • Return type: Task<DeviceCommandResult> or ValueTask<DeviceCommandResult>
  • First parameter: Your Thing type (the target device) - required
  • Second parameter: Command type (e.g., Access.Open) - required
  • Additional parameters: Optional - resolved from DI container or CancellationToken

Example: Minimal Handler

public class DoorCommandHandler
{
    public static Task<DeviceCommandResult> OpenDoor(
        MyDoor door,
        Access.Open command)
    {
        // just Thing + Command, no extras
        door.Unlock();
        return Task.FromResult(new DeviceCommandResult(CommandResult.Success));
    }
}

Example: Handler with Dependencies

public class DoorCommandHandler
{
    public static async Task<DeviceCommandResult> OpenDoor(
        MyDoor door,
        Access.Open command,
        Protocol protocol)  // <-- your class, resolved from DI
    {
        await protocol.UnlockDoor(door.Address, duration: 5);
        return new DeviceCommandResult(CommandResult.Success);
    }
}

What is Protocol? Your class that communicates with the physical device. See Protocol concept for details.

You can request any registered service as a parameter:

public static async Task<DeviceCommandResult> OpenDoor(
    MyDoor door,
    Access.Open command,
    Protocol protocol,                    // your protocol class
    ILogger<DoorCommandHandler> logger,   // framework logging
    CancellationToken cancellationToken)  // cancellation support
{
    ...
}

Framework automatically:

  • Finds your handler by matching MyDoor + Access.Open types
  • Resolves additional parameters from DI
  • Calls your method when command arrives

Handler Patterns

Three ways to write handlers:

public class DoorCommands
{
    public static async Task<DeviceCommandResult> Open(
        MyDoor door,
        Access.Open command,
        Protocol protocol)
    {
        await protocol.UnlockDoor(door.Address);
        return new DeviceCommandResult(CommandResult.Success);
    }

    public static async Task<DeviceCommandResult> Lock(
        MyDoor door,
        Access.Lock command,
        Protocol protocol)
    {
        await protocol.LockDoor(door.Address);
        return new DeviceCommandResult(CommandResult.Success);
    }
}

Simple, no state, dependencies injected as parameters.

2. Instance Method

public class DoorCommandHandler
{
    private readonly ILogger<DoorCommandHandler> _logger;

    public DoorCommandHandler(ILogger<DoorCommandHandler> logger)
    {
        _logger = logger;
    }

    public async Task<DeviceCommandResult> Handle(
        MyDoor door,
        Access.Open command,
        Protocol protocol)
    {
        _logger.LogInformation("Opening door {Address}", door.Address);
        await protocol.UnlockDoor(door.Address);
        return new DeviceCommandResult(CommandResult.Success);
    }
}

Framework creates instance via DI, resolves constructor dependencies.

3. Self-Handler (on Thing itself)

public partial class MyDoor : Thing
{
    public async Task<DeviceCommandResult> Handle(
        Access.Open command,
        Protocol protocol)
    {
        // 'this' is the door
        await protocol.UnlockDoor(this.Address);
        return new DeviceCommandResult(CommandResult.Success);
    }
}

Handler lives on the Thing class itself. No separate Thing parameter needed.


Command Types

Commands live in Pq.Adapters.Framework.Commands namespace, organized by category:

using Pq.Adapters.Framework.Commands;

// Access commands
Access.Open
Access.Lock
Access.Unlock
Access.Synchronize

// Intrusion commands
Intrusion.Arm
Intrusion.Disarm

Use them in handler signatures:

public Task<DeviceCommandResult> Handle(MyDoor door, Access.Open command)

Each command has OperatorIdentity property - the user who triggered it.


Returning Results

// success
return DeviceCommandResult.Succeeded();

// failure
return DeviceCommandResult.Failed();

// timeout
return new DeviceCommandResult(CommandResult.Timeout);

Handlers should not return CommandResult.NotFound; the dispatcher uses it when the target device or command handler cannot be found.


Accessing Command Data

Some commands carry additional data:

public async Task<DeviceCommandResult> Handle(
    MyDoor door,
    Access.Open command)
{
    // who triggered this command
    var operatorId = command.OperatorIdentity;

    await _protocol.UnlockDoor(door.Address);
    return new DeviceCommandResult(CommandResult.Success);
}

Complete Example

// adapter-registration.yaml declares: pq.command.access.open

public class DoorCommands
{
    public static async Task<DeviceCommandResult> OpenDoor(
        DoorDevice door,
        Access.Open command,
        Protocol protocol,
        ILogger<DoorCommands> logger)
    {
        logger.LogInformation(
            "Opening door {DoorId} requested by {Operator}",
            door.DeviceId,
            command.OperatorIdentity);

        try
        {
            await protocol.UnlockDoor(door.Address, duration: 5);
            return new DeviceCommandResult(CommandResult.Success);
        }
        catch (DeviceOfflineException)
        {
            logger.LogWarning("Door {DoorId} is offline", door.DeviceId);
            return new DeviceCommandResult(CommandResult.Failure);
        }
    }
}

ExecuteCommand Pattern

For commands that update status, use ExecuteCommand extension method. It handles:

  • Optimistic pending status (UI shows "in progress")
  • ACK handling
  • Event confirmation (optional)
  • Automatic revert on failure

Immediate Command (ACK only)

Device acknowledges immediately, no follow-up event:

public async Task<DeviceCommandResult> Handle(
    MyDoor door,
    Access.Lock command,
    Protocol protocol)
{
    return await door.ExecuteCommand(
        pendingState: DoorState.Secured,
        send: () => protocol.SendLock(door.Address),
        onSuccess: () => door.SecuredRemote(DeviceTimestamp.UtcNow, command.OperatorIdentity));
}

Flow:

  1. Set secured.pending status
  2. Send command, wait for ACK
  3. On ACK: call onSuccess, return CommandResult.Success
  4. On failure: revert status, return CommandResult.Failure

Event-Supervised Command

Device ACKs command, then writes/emits an audited event when action completes:

public async Task<DeviceCommandResult> Handle(
    MyDoor door,
    Access.Open command,
    Protocol protocol)
{
    return await door.ExecuteCommand(
        pendingState: DoorState.Unsecured,
        send: () => protocol.SendOpen(door.Address),
        protocol: protocol,
        predicate: e => e.Type == EventType.StrikeReleased && e.DoorId == door.Address,
        onSuccess: evt => door.UnsecuredRemote(evt.Timestamp, command.OperatorIdentity));
}

Flow:

  1. Set unsecured.pending status
  2. Register event intercept (before send to avoid race)
  3. Send command, wait for ACK
  4. On ACK: return Succeeded immediately (command accepted by device/control system)
  5. Background: wait for audited execution event
  6. On event: call onSuccess with event data
  7. On timeout: revert status, call onFault

DeviceCommandResult reports command acceptance, not final physical execution. This matters for delayed actions such as intrusion arming with an exit-delay timer: the command can be accepted now, while the audited Armed event may arrive minutes later. ExecuteCommand keeps the Thing pending and supervises that later execution event.

The predicate should match the protocol's audited event/log entry for the action. Do not use ordinary status-change or polling snapshots as the pairing source when the protocol provides audited events; status callbacks are a documented fallback only when no audited event exists.

sequenceDiagram
    participant UI
    participant Thing
    participant Protocol
    participant Device

    UI->>Thing: Command
    Thing->>Thing: Set pending status
    Thing->>Protocol: Register intercept
    Thing->>Device: Send command
    Device-->>Thing: ACK
    Thing-->>UI: Succeeded (optimistic)

    Note over Device: Action completes

    Device->>Thing: Confirming event
    Thing->>Thing: Set final status

Summary

  1. Declare supported commands in adapter-registration.yaml
  2. Write handler method with signature: Thing + Command -> DeviceCommandResult
  3. Add DI parameters as needed
  4. Use ExecuteCommand for status-updating commands
  5. Framework routes commands to your handlers automatically

Next: Properties - Device configuration