Pattern 13: Optimistic Command Execution¶
Problem¶
When a user sends a command (e.g., Open Door), the device acts immediately but the UI waits for a device event to update status. This creates a 1-5 second lag that makes the system feel slow.
Solution¶
Use ExecuteCommand extension method on Thing for optimistic status updates:
- Sets pending status immediately (UI shows spinner)
- Returns
Succeededafter device ACK (command handler ends fast) - Confirms final status asynchronously via device event
- Protects pending status from poll overwrite
Two Modes¶
Event-Confirmed Commands¶
For commands where the device sends a confirmation event (Door, ControlPoint, MPG):
public static async Task<DeviceCommandResult> Open(
DoorDevice door,
Access.Open command,
MyProtocol protocol,
CancellationToken ct)
{
return await door.ExecuteCommand(
DoorState.UnsecuredClosed,
send: async () =>
{
var reply = await protocol.SendOpenCommand(door.Address, ct);
return reply.IsSuccess;
},
protocol,
predicate: e => e.EventType == EventType.StrikeReleased
&& e.SourceAddress == door.Address,
onSuccess: async evt =>
{
var ts = DeviceTimestamp.FromUnixSeconds(evt.Timestamp);
await door.UnsecuredRemote(ts, command.OperatorIdentity);
});
}
Lifecycle:
pending → send → ACK → return Succeeded → ... → event → final + onSuccess
└→ timeout (30s) → revert + onFault
└→ ACK fail → revert + onFault → return Failed
Immediate Commands¶
For commands without confirmation events (AlarmInput Bypass, DoorAlarm Bypass):
public static async Task<DeviceCommandResult> Bypass(
AlarmInputDevice ai,
Monitoring.Bypass command,
MyProtocol protocol,
CancellationToken ct)
{
return await ai.ExecuteCommand(
SecurityState.Bypassed,
send: async () =>
{
var reply = await protocol.SendBypassCommand(ai.Address, ct);
return reply.IsSuccess;
},
onSuccess: async () => await ai.Bypassed(DeviceTimestamp.UtcNow, command.OperatorIdentity));
}
Lifecycle:
pending → send → ACK → final + onSuccess → return Succeeded
└→ ACK fail → revert + onFault → return Failed
ACK-Only Commands¶
For commands where even an immediate final state would be misleading because the device only confirms acceptance, use a plain handler that sends the command and returns the ACK/NACK result. Do not manufacture a PQ event or pending/final state just to satisfy the optimistic command pattern.
public static async Task<DeviceCommandResult> Bypass(
AlarmInputDevice ai,
Monitoring.Bypass command,
MyProtocol protocol,
CancellationToken ct)
{
var accepted = await protocol.SendBypassCommand(ai.Address, ct);
return accepted
? DeviceCommandResult.Succeeded()
: DeviceCommandResult.Failed("Device rejected bypass command.");
}
Document this mode in the adapter docs. A similarly named vendor event does not make the command event-confirmed unless it is reliably emitted for the remote command.
API Reference¶
Event-Confirmed Overload¶
Task<DeviceCommandResult> ExecuteCommand<TEvent, TState>(
this Thing thing,
TState finalState,
Func<Task<bool>> send,
ProtocolBase<TEvent> protocol,
Func<TEvent, bool> predicate,
Func<TEvent, Task>? onSuccess = null,
Func<Task>? onFault = null,
TimeSpan? timeout = null, // default: 30 seconds
CancellationToken ct = default)
where TState : struct, Enum
Immediate Overload¶
Task<DeviceCommandResult> ExecuteCommand<TState>(
this Thing thing,
TState finalState,
Func<Task<bool>> send,
Func<Task>? onSuccess = null,
Func<Task>? onFault = null,
CancellationToken ct = default)
where TState : struct, Enum
Parameters¶
| Parameter | Description |
|---|---|
finalState |
Expected final state enum value (e.g., DoorState.UnsecuredClosed) |
send |
Async function that sends command to device, returns true on ACK |
protocol |
Protocol instance for event interception (event-supervised only) |
predicate |
Filter for matching audited final execution event (event-supervised only) |
onSuccess |
Called after final status is set (receives event for event-supervised) |
onFault |
Called on send failure or timeout - for compensation logic |
timeout |
Max wait for confirmation event, default 30 seconds |
Status String Convention¶
Framework appends .pending suffix to status during command execution:
DoorState.UnsecuredClosed→"UnsecuredClosed.pending"
UI interprets this suffix to show loading indicator. No framework stripping needed.
Poll Protection¶
While a function is pending, ApplyPollSnapshot excludes it from status updates. This prevents poll from overwriting optimistic status before device confirms.
When to Use¶
| Scenario | Mode | Example |
|---|---|---|
| Device sends or exposes audited final execution event | Event-supervised | Door Open/Lock/Unlock, delayed Arm, ControlPoint, MPG |
| No confirmation event expected | Immediate | AlarmInput Bypass, DoorAlarm Bypass |
| Only command acceptance is knowable | Plain ACK-only | Bypass/remove-bypass commands with no reliable remote confirmation |
| Status unknown after command | Neither | Use standard command handling |
Error Handling¶
- Send fails: Reverts to original status, calls
onFault, returnsFailed - Event timeout: Reverts to original status, calls
onFault(fire-and-forget) - Exception in callback:
finally { EndPending() }ensures cleanup
Forbidden Pattern¶
Do not implement state-changing commands by manually waiting for the final state event before returning DeviceCommandResult. This mixes command acceptance with final execution and breaks delayed actions. New adapters must use ExecuteCommand for event-supervised commands from the start.
The event-supervision predicate must target audited device events/log entries, not ordinary status-change callbacks or polling snapshots, unless no audited event exists and the adapter documents that fallback.