Skip to main content

One Camera, Many Hands

It's 2 AM during a wind event. A CAL FIRE dispatcher in Sacramento and a county fire captain in San Diego are both watching the same ridge camera — and both want to point it at different smoke. Cameras are physical machines; they can only look one way at a time. The lease is how the platform decides whose hands are on the wheel, for how long, and what everyone else sees while they wait.

This deep dive is the whole protocol, verified line by line against source. Start by playing both sides of it — move as Operator A, then try to grab the camera as Operator B while A's lease is live:

🧑‍🚒 Operator A
CAL FIRE · Sacramento
○ free to move
no lease
move to take control
🧑‍✈️ Operator B
County Fire · San Diego
○ free to move
Camera idle — no lease. Either operator may move it.

Three things you just experienced are the real semantics: the lease was granted as a side effect of moving (nobody clicked "take lease"), moving again refreshed the clock, and the countdown was visible to both operators — not just the holder. Hold onto that last one; it surprises everyone. Now, the one idea that makes the rest fall into place.

The one idea to hold on to

The frontend does not own the lease. It never has a lease id, never sets a duration, and never closes a lease. The backend (Frontend.APICameraMovement) grants the lease as a side effect of accepting a movement, decides how long it lasts, and expires it. The browser learns about the lease in exactly two ways:

  1. A point-in-time question, asked immediately before a movement: GET /api/lease — "who holds this camera right now?" This is the gate.
  2. A push, ReceiveCurrentConfigBroadcast over SignalR, which is the only thing that ever starts the countdown clock. This is the display.

Those two paths are independent, and conflating them is the single most common source of confusion in this area. The gate decides whether you are allowed to move the camera. The push decides whether a timer appears on screen — for everyone in the camera group, not just the lease holder. See Surprises.

The three endpoints

RouteMethodBackend pathWhat it answers
pages/api/lease.tsGET/CameraMovement/lease/ + camera idWho holds the lease right now
pages/api/leased.tsGET/CameraMovement/latest-move-by-camera/ + camera idWho moved this camera last, and why
pages/api/close.tsPUT/CameraMovement/close-lease/ + lease idRelease a lease early — not called anywhere in the UI
lease and leased are different questions

/api/lease is the concurrency gate. /api/leased is display metadata for the movement modal ("Last Moved 4m ago — Jane Doe, CAL FIRE"). They hit different backend endpoints and return different shapes. The one-letter difference has bitten people; read the serviceTitle string in the route ('leasing camera' vs 'fetching lease data') if you are not sure which one you are looking at.

All three are thin withApiHandler passthroughs. They do not implement any lease logic: they validate input with Zod, forward req.headers.authorization (injected upstream by proxy.ts — see security/authentication), and return the backend's payload. All three are listed in the proxy.ts matcher, so they get a bearer token; a route missing from that array would get none.

Validation is worth noting because it is the only place these routes can fail on their own:

  • lease.ts and leased.ts use querySchema: z.object({ cameraId: CameraIdSchema }). CameraIdSchema is z.string().min(1).regex(/^[a-zA-Z0-9_-]+$/) — a camera id with a dot or a space is rejected with 400 Validation failed before the backend is ever called.
  • close.ts uses bodySchema: CloseLeasePayloadSchema, which extends the generated CameraLeaseRequestSchema and requires the full lease record (id, cameraName, entraUserId, entraDisplayName, companyName, startTime, endTime, requestedDurationSeconds, actualDurationSeconds). It also returns 204 rather than 200 when the backend returns no content.

The multi-user protocol

Two operators, one camera. Operator A takes control; Operator B is watching the same camera in Location View.

The SignalR half of that diagram — how the connection is built, how a client joins exactly one camera group, how ReceiveCurrentConfigBroadcast is parsed and routed into cameraMovementStore.setLeaseFromSignalR — is documented in full in deep-dives/signalr-realtime-architecture. This page picks up where that handler ends.

The gate: moveCamera()

Every movement path in XYMouseTrackerOverlay.tsx — click, drag, zoom slider, focus, brightness — funnels into one function. The whole concurrency decision is four lines:

const { leasedByAnother, leasedBySelf } = await checkCameraLeaseStatus(
cameraSelected.cId,
);
const freshRecentMovementPayload =
useCameraMovementStore.getState().recentMovementPayload;
const allowCamMove =
isLeaseConfirmed ||
leasedBySelf ||
(freshRecentMovementPayload != null && !leasedByAnother);

Read the three disjuncts as three ways of already having permission:

TermMeaning
isLeaseConfirmedThe caller passed true. Exactly one call site does: onCamMovementModalConfirmed, i.e. the user already clicked Confirm in the modal. Every other call site passes false
leasedBySelf/api/lease says the current holder's entraUserId equals session.user.id — you already hold this camera, so keep going without another modal
recentMovementPayload != null && !leasedByAnotherYou have moved this camera recently in this session and nobody else has taken it — the "don't re-prompt me every click" path

If allowCamMove is false, the payload is stashed in pendingModalPayloadRef before the modal opens (so a second click during the async lease check cannot overwrite what the user is about to confirm) and setIsMovementModalOpen(true) fires. leasedByAnother is also pushed into isSelectedCameraLeased, which the modal reads as isLeaseWarningMode — that is what switches the modal from "confirm your movement" to the "another user is controlling this camera" acknowledgement screen.

The lease read itself is a small utility in util/index.ts:

export const checkWhetherCameraIsLeasedByAnotherUserUtil = async (
cameraId,
sessionUserId,
isCameraLeasedFn,
onError,
) => {
const camLeasedResponse = await isCameraLeasedFn(cameraId);
const isLeased = !!camLeasedResponse && camLeasedResponse.entraUserId != null;
const leasedBySelf =
isLeased && camLeasedResponse.entraUserId === sessionUserId;
const leasedByAnother = isLeased && !leasedBySelf;
return { leasedByAnother, leasedBySelf };
};

It is injected with IsCameraLeased rather than importing it, purely so the unit tests can substitute a fake. Note the failure mode: catch returns { leasedByAnother: false, leasedBySelf: false } and surfaces a "Failed To Check Camera's Lease" notification. A failed lease check therefore does not block the movement — it falls through to the modal, and the backend remains the real authority.

The countdown

secondsRemainingInLeaseMode lives in stores/cameraMovementStore.ts. Only four things write it, and none of them is the movement API:

  1. setLeaseFromSignalR(currentConfig, leaseModeEndDt) — the broadcast handler.
  2. The useCameraMovementEffects tick.
  3. onDialogClosed on the lease popup, which sets it to 0.
  4. TileViewPage's useCameraStaticById success callback, which zeroes it when the selected camera changes.

Debugging checklist

  • Timer never appears — check enable-all-features-connected-to-signalr first, then whether the client actually joined the camera group (SignalR_ReceiveCurrentConfigBroadcast in App Insights carries groupId and the full payload).
  • Timer appears but immediately reads 0leaseModeEndDt failed to parse. The App Insights event has fullPayload; check whether the +00:00 offset survived, and compare against the two formats parseLeaseModeEndDt accepts.
  • Modal appears on every single clickrecentMovementPayload is being cleared. Either the countdown reached 0 (the effect clears it), or another operator took the lease so leasedByAnother is true.
  • Movement modal never appears and the camera does not move — look for a backend rejection on /api/ptz; a false response body shows as Error Moving Camera with no further detail on the client.
  • 400 Validation failed from /api/lease — the camera id contains a character outside [a-zA-Z0-9_-].