Skip to main content

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:

EndpointPurpose
QueueAutomatedPtzEnqueue pan/tilt/zoom, focus, and/or brightness in one call
CloseAutomatedPtzLeaseSignal that the system is done, closing the lease early
QueueAutomatedIrCutFilterEnqueue 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​

PTZ move only
{
"CameraId": "siodemo-cam01",
"Pan": 12.5,
"Tilt": -4.25,
"Zoom": 2
}
PTZ + focus + brightness in one call
{
"CameraId": "siodemo-cam01",
"Pan": 12.5,
"Tilt": -4.25,
"Zoom": 2,
"Focus": 300,
"AutoFocusEnabled": true,
"Brightness": 9000
}
Focus only
{
"CameraId": "siodemo-cam01",
"Focus": 300,
"AutoFocusEnabled": true
}
Brightness only
{
"CameraId": "siodemo-cam01",
"Brightness": 9000
}
Reset brightness to the camera's default
{
"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.

Close the lease once done sending PTZ calls
{
"CameraId": "siodemo-cam01"
}
Gotcha: the SQL close happens on the next orchestration cycle, not immediately

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.

Force color mode
{
"CameraId": "siodemo-cam01",
"Mode": "on"
}
Force night vision (NIR)
{
"CameraId": "siodemo-cam01",
"Mode": "off"
}
Let the camera decide
{
"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.