Automated System Control
An operator driving PTZ from the Admin API always has an Entra identity attached to the request — that's who gets credited (or blamed) for the move. But some camera control doesn't come from a person at all: a fire-alert dispatch system might swing a camera toward a detection, or an automated pipeline might switch a camera to night vision when the sun goes down. Those callers don't have a user identity, and they shouldn't be forced to fake one. Automated control gets its own endpoints instead of overloading the operator-facing ones.
The one idea to hold on to
Automated requests get simpler, purpose-built endpoints that assign a fixed system identity, create the same lease the operator path creates, and enqueue onto the same command queue documented in One Move at a Time. Nothing about the queue or the lease mechanism changes — only who's asking and what they're allowed to say.
Three endpoints exist:
| Endpoint | Purpose |
|---|---|
QueueAutomatedPtz | Enqueue pan/tilt/zoom, focus, and/or brightness in one call |
CloseAutomatedPtzLease | Signal that the system is done, closing the lease early |
QueueAutomatedIrCutFilter | Enqueue an IR-cut filter mode change |
Each one is gated by a per-device flag — AutoPTZEnabled or AutoIREnabled on the
AcquisitionDeviceEntity — so a device has to opt in before an external system can move it or
change its imaging mode. A request for a device without the flag set returns 400.
QueueAutomatedPtz: one call, up to three commands
Unlike the operator-facing QueuePanTiltZoomCamera / QueueSetCameraFocus /
QueueSetCameraBrightness endpoints — which each take one MoveCameraRequestWrapper and enqueue
one trigger — QueueAutomatedPtz takes a single flat request and enqueues one command per
operation supplied:
if (request.HasPanTiltZoom)
await durableClient.Entities.SignalEntityAsync(entityInstance,
nameof(CameraState.AddMoveCommand), new QueueCommand(nameof(MoveCameraTrigger), ...));
if (request.HasFocus)
await durableClient.Entities.SignalEntityAsync(entityInstance,
nameof(CameraState.AddMoveCommand), new QueueCommand(nameof(FocusCameraTrigger), ...));
if (request.HasBrightness)
await durableClient.Entities.SignalEntityAsync(entityInstance,
nameof(CameraState.AddMoveCommand), new QueueCommand(nameof(CameraBrightnessTrigger), ...));
Because the queue drains one command per orchestration cycle (see One Move at a Time), a request with all three fields set takes three cycles to fully apply — pan/tilt/zoom first, then focus, then brightness — not one atomic move.
Before touching the queue, the handler creates a five-minute system lease through
ICameraLeaseRepository.ExecuteLeaseProcess with a fixed identity:
var requestBase = new MoveCameraOperationBase
{
CameraId = request.CameraId,
EntraUserId = "system-automated-ptz",
EntraDisplayName = "System (Automated PTZ)",
Email = "system@alertcalifornia.org",
CompanyName = "ALERTCalifornia",
ReasonCode = "AutomatedPTZ",
Message = "Automated PTZ operation",
IsHighPriority = true
};
That's the whole point of a dedicated endpoint: the caller never supplies EntraUserId or any
other identity field — it can't, because AutomatedPtzRequest doesn't have them. The audit trail
still shows who acted (system-automated-ptz), it just isn't a person.
Sample payloads
{
"CameraId": "siodemo-cam01",
"Pan": 12.5,
"Tilt": -4.25,
"Zoom": 2
}
{
"CameraId": "siodemo-cam01",
"Pan": 12.5,
"Tilt": -4.25,
"Zoom": 2,
"Focus": 300,
"AutoFocusEnabled": true,
"Brightness": 9000
}
{
"CameraId": "siodemo-cam01",
"Focus": 300,
"AutoFocusEnabled": true
}
{
"CameraId": "siodemo-cam01",
"Brightness": 9000
}
{
"CameraId": "siodemo-cam01",
"ResetBrightnessToDefault": true
}
Pan and Tilt must be supplied together — one without the other is a 400. At least one of
PTZ, focus, or brightness must be present, or there's nothing to enqueue.
Closing the lease early
The five-minute system lease closes itself on a timeout, same as any operator lease (see
Four Modes, One Loop). But a system that knows it's finished sending
PTZ calls doesn't need to wait out the clock — CloseAutomatedPtzLease nudges the lease's end
time into the past instead of flipping the closed flag directly:
await durableClient.Entities.SignalEntityAsync(
GetEntityInstanceId(request.CameraId),
nameof(CameraState.SetLeaseModeEndDt),
DateTimeOffset.UtcNow);
This matters because BaseOrchestrationFunctions
only runs its SQL-closing logic — calling ReviewLeaseStatus and updating the CameraLease row's
EndTime — when !entityState.IsLeaseClosed is still true. Signaling SetIsLeaseClosed(true)
directly from the endpoint would make the orchestration skip that branch entirely on its next
cycle, so the entity would say the lease is closed while the SQL row never got its EndTime set.
By instead moving LeaseModeEndDt into the past, the orchestrator's normal "lease has expired"
path runs on its very next iteration: it calls ReviewLeaseStatus, which closes the lease in SQL,
and only then sets IsLeaseClosed itself — the same sequence that happens for a natural timeout,
just triggered early.
{
"CameraId": "siodemo-cam01"
}
CloseAutomatedPtzLease returns as soon as the signal is queued — it doesn't wait for the
orchestration to wake up and actually run ReviewLeaseStatus. Depending on the lease mode's
RateInSeconds, there can be a brief delay (typically a few seconds) between calling this
endpoint and the SQL CameraLease.EndTime actually being set.
QueueAutomatedIrCutFilter: its own endpoint, not a flag on the operator one
IR-cut filter control started as a single endpoint (QueueSetIrCutFilter) with an
IsAutomated flag on the trigger — automated callers set the flag and the handler substituted a
system identity for whatever was in the request body. That worked, but it meant one endpoint had
two different validation paths interleaved, and an automated caller still had to send a full
MoveCameraRequestWrapper shaped for a human operator.
QueueAutomatedIrCutFilter replaces that with a request shaped for the caller it actually serves:
public sealed class AutomatedIrCutFilterRequest
{
public required string CameraId { get; init; }
public required string Mode { get; init; }
}
QueueSetIrCutFilter now rejects any request with IsAutomated: true, pointing the caller at
the new endpoint instead — so there's exactly one path for each kind of caller, not one endpoint
branching on who's asking.
{
"CameraId": "siodemo-cam01",
"Mode": "on"
}
{
"CameraId": "siodemo-cam01",
"Mode": "off"
}
{
"CameraId": "siodemo-cam01",
"Mode": "auto"
}
Mode is validated against on / off / auto (case-insensitive) before anything else runs.
Everything past that point mirrors the PTZ path: check AutoIREnabled, then enqueue an
IrCutFilterTrigger command — which the orchestration loop runs through SetIrCutFilter followed
by the 2-second white-balance settling timer, exactly as it does for an operator-triggered IR
change. No lease is written at queue time. Once the camera accepts the new mode, SetIrCutFilter
posts to Frontend.API's leaseCameraAfterMove callback (the same one brightness and focus use),
which writes the lease-and-movement record under the system identity and patches
irCutFilterMode on the operational document so the frontend shows the current mode.
Wrapping up
Automated system control reuses every mechanism the operator path already has — the command
queue, the lease, the orchestration's white-balance timer — but replaces the operator-facing
request shape with a simpler one that has no identity fields to fill in, and gates access behind
a per-device AutoPTZEnabled / AutoIREnabled flag. QueueAutomatedPtz can enqueue up to three
commands per call, CloseAutomatedPtzLease lets a system end its own lease early, and
QueueAutomatedIrCutFilter gives IR requests the same treatment instead of overloading the
operator endpoint with an IsAutomated branch.
Next, the broader mode machine these commands feed into: Four Modes, One Loop.