One Move at a Time
An operator grabs a camera during a fire and drags it toward the smoke. That gesture can't run the PTZ move inline: the camera might be mid-capture, the physical move takes real time, and the operator might fire off five adjustments in two seconds. So a movement request never touches the camera directly. It gets queued onto the camera's runtime entity, and the orchestration loop — the lean loop from the last lesson — drains it.
The one idea to hold on to
Producing a move and processing a move are decoupled by the entity's command queue. The HTTP endpoint just records intent and returns; the loop does the work later, one command at a time.
The producing side is a set of HTTP functions (QueuePanTiltZoomCamera, QueueMoveCameraHome,
QueueSetCameraFocus, …). None of them talk to the camera. Each signals the entity and
returns immediately:
await durableClient.Entities.SignalEntityAsync(
entityInstance, nameof(CameraState.AddMoveCommand),
new QueueCommand(nameof(MoveCameraTrigger), wrapper));
return new OkObjectResult("PanTiltZoomCamera command enqueued successfully.");
The consuming side is the orchestration. Each cycle it checks the queue, and if something's there it peeks the front command, runs its activity, and dequeues it — exactly one per loop. Play both sides: fire off some moves, then run the loop and watch it drain:
Idle in SINGLE mode — capturing on the standard schedule.
Three things you just saw are the real semantics: the queue lives on the entity (so it
survives restarts — the loop rebuilds from it via replay), commands drain one per cycle
(FIFO, Peek then DequeueTopCommand), and the first pending move flips the camera into
LEASED mode — except move-home, which isn't a leasing action and drops back to SINGLE.
Why one-per-loop, and why lease mode runs fast
Processing a single command per cycle buys three things. It keeps each loop small and deterministic — no unbounded work, no giant history in one pass (the replay cost from the previous lesson). It bounds the work per cycle so the loop stays fast. And — the reason that's easy to miss — because a capture happens right after the command is processed, the very next image is of that operation's result: every move, focus, or brightness change is tied to a fresh image the operator can use to confirm the camera actually did what they asked. One command, one confirming frame.
The trade-off is that a burst of five moves takes five cycles to clear. That is precisely why lease mode sets a ~1-second wake-up rate: draining one-per-loop only feels responsive if the loop comes right back around. The queue design and the fast-lease-loop design are two halves of the same decision.
QueuePanTiltZoomCamera returns "enqueued successfully" the moment the signal is accepted —
it says nothing about whether a move happened. If the camera's orchestration isn't running (never
deployed, purged, or stopped), commands pile onto the entity and are never drained. A
successful enqueue means "the entity received it," not "the camera moved." Check that the
orchestration is alive before trusting the queue.
Wrapping up
Movements are decoupled from their execution by a queue that lives on the CameraState
entity: HTTP functions signal AddMoveCommand and return; the loop peeks, runs the matching
activity, and dequeues exactly one command per cycle, flipping to LEASED on the first move.
One-per-loop keeps each cycle lean, and the fast lease rate is what makes that feel instant.
Remember that a successful enqueue only means the entity got the command.
That "flip to LEASED" is one edge of a larger state machine. But first, a note on who's allowed to queue commands without a human behind them: Automated System Control.