Files
frigate/web/src/api/ws.ts
T
Josh HawkinsandGitHub 729ee86043
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / Jetson Jetpack 6 (push) Waiting to run
Miscellaneous fixes (#23619)
* fix stale active object indicators on the live dashboard

The camera_activity/<camera> snapshot cache is only written when a client sends onConnect, and object "end" events only update the local state of mounted useCameraActivity hooks, never the cache. As a result, a hook that seeded from a stale cache or missed an "end" event while disconnected showed objects that had already left, with no path to correct itself short of a full page reload.

This change will re-request the snapshot on hook mount (collapsed to one onConnect per task across camera cards), and always re-notify camera_activity topics so hooks reconcile against their own local state instead of relying on snapshot-vs-snapshot comparison, and clear the payload dedup cache on reconnect and resync so byte-identical snapshots still apply.

* docs tweaks

* fix mqtt log message

* use consistent values for lpr debug frame filenames

with millisecond resolution

* apply object events through a functional updater to prevent lost updates

The events effect derived a new objects list from the value captured at render time and wrote the whole list back. When events arrived close
together, a run derived from a stale list erased a concurrent run's removal; the resurrected object then had no remaining "end" event to clear it, and the add branch could mint a duplicate entry that no splice could ever remove, leaving the live dashboard showing active objects the backend had already cleared, until a page reload.

The fix is to apply each event inside setObjects so it operates on the true current list exactly once. Unchanged results return the same reference so React bails out of re-rendering, and the label rewrite is hoisted so added objects get the sub_label/verified label directly instead of relying on the effect re-running against its own state update.
2026-07-04 00:25:22 -06:00

887 lines
23 KiB
TypeScript

import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from "react";
import {
EmbeddingsReindexProgressType,
FrigateCameraState,
FrigateEvent,
FrigateReview,
ModelState,
ToggleableSetting,
TrackedObjectUpdateReturnType,
TriggerStatus,
FrigateAudioDetections,
Job,
} from "@/types/ws";
import { FrigateStats } from "@/types/stats";
import { isEqual } from "lodash";
import { WsSendContext } from "./wsContext";
import type { Update, WsSend } from "./wsContext";
export type { Update };
export type WsFeedMessage = {
topic: string;
payload: unknown;
timestamp: number;
id: string;
};
type WsState = {
[topic: string]: unknown;
};
// External store for WebSocket state using useSyncExternalStore
type Listener = () => void;
const wsState: WsState = {};
const wsTopicListeners = new Map<string, Set<Listener>>();
// Reset all module-level state. Called on WsProvider unmount to prevent
// stale data from leaking across mount/unmount cycles (e.g. HMR, logout)
export function resetWsStore() {
for (const key of Object.keys(wsState)) {
delete wsState[key];
}
wsTopicListeners.clear();
lastCameraActivityPayload = null;
wsMessageSubscribers.clear();
wsMessageIdCounter = 0;
}
// Parse and apply a raw WS message synchronously.
// Called directly from WsProvider's onmessage handler.
export function processWsMessage(raw: string) {
const data: Update = JSON.parse(raw);
if (!data) return;
const { topic, payload } = data;
if (topic === "camera_activity") {
applyCameraActivity(payload as string);
} else {
applyTopicUpdate(topic, payload);
}
if (wsMessageSubscribers.size > 0) {
wsMessageSubscribers.forEach((cb) =>
cb({
topic,
payload,
timestamp: Date.now(),
id: String(wsMessageIdCounter++),
}),
);
}
}
function applyTopicUpdate(topic: string, newVal: unknown) {
const oldVal = wsState[topic];
// camera_activity snapshots always re-notify: consumers reconcile local
// state that may have diverged from an unchanged snapshot
const isActivitySnapshot = topic.startsWith("camera_activity/");
// Fast path: === for primitives ("ON"/"OFF", numbers).
// Fall back to isEqual for objects/arrays.
const unchanged =
oldVal === newVal ||
(typeof newVal === "object" && newVal !== null && isEqual(oldVal, newVal));
if (unchanged && !isActivitySnapshot) return;
wsState[topic] = newVal;
// Snapshot the Set — a listener may trigger unmount that modifies it.
const listeners = wsTopicListeners.get(topic);
if (listeners) {
for (const l of Array.from(listeners)) l();
}
}
// Subscriptions
export function subscribeWsTopic(
topic: string,
listener: Listener,
): () => void {
let set = wsTopicListeners.get(topic);
if (!set) {
set = new Set();
wsTopicListeners.set(topic, set);
}
set.add(listener);
return () => {
set!.delete(listener);
if (set!.size === 0) wsTopicListeners.delete(topic);
};
}
export function getWsTopicValue(topic: string): unknown {
return wsState[topic];
}
// Feed message subscribers
const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>();
let wsMessageIdCounter = 0;
// Camera activity expansion
//
// Cache the last raw camera_activity JSON string so we can skip JSON.parse
// and the entire expansion when nothing has changed. This avoids creating
// fresh objects (which defeat Object.is and force expensive isEqual deep
// traversals) on every flush — critical with many cameras.
let lastCameraActivityPayload: string | null = null;
// Make the next camera_activity snapshot fully apply even when byte-identical
// to the previous one — local state may have diverged while no messages flowed
export function invalidateCameraActivityCache() {
lastCameraActivityPayload = null;
}
// Collapse same-task resync requests (one hook per camera card) into a
// single onConnect round-trip
let resyncScheduled = false;
function requestCameraActivityResync(sendOnConnect: () => void) {
if (resyncScheduled) return;
resyncScheduled = true;
queueMicrotask(() => {
resyncScheduled = false;
invalidateCameraActivityCache();
sendOnConnect();
});
}
function applyCameraActivity(payload: string) {
// Fast path: if the raw JSON string is identical, nothing changed.
if (payload === lastCameraActivityPayload) return;
lastCameraActivityPayload = payload;
let activity: { [key: string]: Partial<FrigateCameraState> };
try {
activity = JSON.parse(payload);
} catch {
return;
}
if (Object.keys(activity).length === 0) return;
for (const [name, state] of Object.entries(activity)) {
applyTopicUpdate(`camera_activity/${name}`, state);
const cameraConfig = state?.config;
if (!cameraConfig) continue;
const {
record,
detect,
enabled,
snapshots,
audio,
audio_transcription,
notifications,
notifications_suspended,
autotracking,
alerts,
detections,
object_descriptions,
review_descriptions,
} = cameraConfig;
applyTopicUpdate(`${name}/recordings/state`, record ? "ON" : "OFF");
applyTopicUpdate(`${name}/enabled/state`, enabled ? "ON" : "OFF");
applyTopicUpdate(`${name}/detect/state`, detect ? "ON" : "OFF");
applyTopicUpdate(`${name}/snapshots/state`, snapshots ? "ON" : "OFF");
applyTopicUpdate(`${name}/audio/state`, audio ? "ON" : "OFF");
applyTopicUpdate(
`${name}/audio_transcription/state`,
audio_transcription ? "ON" : "OFF",
);
applyTopicUpdate(
`${name}/notifications/state`,
notifications ? "ON" : "OFF",
);
applyTopicUpdate(
`${name}/notifications/suspended`,
notifications_suspended || 0,
);
applyTopicUpdate(
`${name}/ptz_autotracker/state`,
autotracking ? "ON" : "OFF",
);
applyTopicUpdate(`${name}/review_alerts/state`, alerts ? "ON" : "OFF");
applyTopicUpdate(
`${name}/review_detections/state`,
detections ? "ON" : "OFF",
);
applyTopicUpdate(
`${name}/object_descriptions/state`,
object_descriptions ? "ON" : "OFF",
);
applyTopicUpdate(
`${name}/review_descriptions/state`,
review_descriptions ? "ON" : "OFF",
);
}
}
// Hooks
export function useWsUpdate(): WsSend {
const send = useContext(WsSendContext);
if (!send) {
throw new Error("useWsUpdate must be used within WsProvider");
}
return send;
}
// Subscribe to a single WS topic with proper bail-out.
// Only re-renders when the topic's value changes (Object.is comparison).
// Uses useSyncExternalStore — zero useEffect, so no PassiveMask flags
// propagate through the fiber tree.
export function useWs(watchTopic: string, publishTopic: string) {
const payload = useSyncExternalStore(
useCallback(
(listener: Listener) => subscribeWsTopic(watchTopic, listener),
[watchTopic],
),
useCallback(() => wsState[watchTopic], [watchTopic]),
);
const sendJsonMessage = useWsUpdate();
const value = { payload: payload ?? null };
const send = useCallback(
(payload: unknown, retain = false) => {
sendJsonMessage({
topic: publishTopic || watchTopic,
payload,
retain,
});
},
[sendJsonMessage, watchTopic, publishTopic],
);
return { value, send };
}
// Convenience hooks
export function useEnabledState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`);
return { payload: payload as ToggleableSetting, send };
}
export function useDetectState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/detect/state`, `${camera}/detect/set`);
return { payload: payload as ToggleableSetting, send };
}
export function useRecordingsState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/recordings/state`, `${camera}/recordings/set`);
return { payload: payload as ToggleableSetting, send };
}
export function useSnapshotsState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/snapshots/state`, `${camera}/snapshots/set`);
return { payload: payload as ToggleableSetting, send };
}
export function useAudioState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/audio/state`, `${camera}/audio/set`);
return { payload: payload as ToggleableSetting, send };
}
export function useAudioTranscriptionState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/audio_transcription/state`,
`${camera}/audio_transcription/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function useAutotrackingState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/ptz_autotracker/state`, `${camera}/ptz_autotracker/set`);
return { payload: payload as ToggleableSetting, send };
}
export function useAlertsState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/review_alerts/state`, `${camera}/review_alerts/set`);
return { payload: payload as ToggleableSetting, send };
}
export function useDetectionsState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/review_detections/state`,
`${camera}/review_detections/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function useObjectDescriptionState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/object_descriptions/state`,
`${camera}/object_descriptions/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function useReviewDescriptionState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/review_descriptions/state`,
`${camera}/review_descriptions/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function useMotionMaskState(
camera: string,
maskName: string,
): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/motion_mask/${maskName}/state`,
`${camera}/motion_mask/${maskName}/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function useObjectMaskState(
camera: string,
maskName: string,
): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/object_mask/${maskName}/state`,
`${camera}/object_mask/${maskName}/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function useZoneState(
camera: string,
zoneName: string,
): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/zone/${zoneName}/state`,
`${camera}/zone/${zoneName}/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function usePtzCommand(camera: string): {
payload: string;
send: (payload: string, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/ptz`, `${camera}/ptz`);
return { payload: payload as string, send };
}
export function useRestart(): {
payload: string;
send: (payload: string, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs("restart", "restart");
return { payload: payload as string, send };
}
export function useFrigateEvents(): { payload: FrigateEvent } {
const {
value: { payload },
} = useWs("events", "");
const parsed = useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
return { payload: parsed };
}
export function useAudioDetections(): { payload: FrigateAudioDetections } {
const {
value: { payload },
} = useWs("audio_detections", "");
const parsed = useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
return { payload: parsed };
}
export function useFrigateReviews(): FrigateReview {
const {
value: { payload },
} = useWs("reviews", "");
return useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
}
export function useFrigateStats(): FrigateStats {
const {
value: { payload },
} = useWs("stats", "");
return useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
}
export function useInitialCameraState(
camera: string,
revalidateOnFocus: boolean,
): {
payload: FrigateCameraState;
} {
const {
value: { payload },
send: sendCommand,
} = useWs(`camera_activity/${camera}`, "onConnect");
// camera_activity sub-topic payload is already parsed by expandCameraActivity
const data = payload as FrigateCameraState | undefined;
// the cached snapshot is only written on onConnect and can be stale by the
// time this hook mounts — re-request on mount and when the user tabs back in
useEffect(() => {
if (!revalidateOnFocus) return;
requestCameraActivityResync(() => sendCommand("onConnect"));
const listener = () => {
if (document.visibilityState === "visible") {
requestCameraActivityResync(() => sendCommand("onConnect"));
}
};
addEventListener("visibilitychange", listener);
return () => {
removeEventListener("visibilitychange", listener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revalidateOnFocus]);
return { payload: data as FrigateCameraState };
}
export function useModelState(
model: string,
revalidateOnFocus: boolean = true,
): { payload: ModelState } {
const {
value: { payload },
send: sendCommand,
} = useWs("model_state", "modelState");
const data = useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
useEffect(() => {
let listener = undefined;
if (revalidateOnFocus) {
sendCommand("modelState");
listener = () => {
if (document.visibilityState == "visible") {
sendCommand("modelState");
}
};
addEventListener("visibilitychange", listener);
}
return () => {
if (listener) {
removeEventListener("visibilitychange", listener);
}
};
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revalidateOnFocus]);
return { payload: data ? data[model] : undefined };
}
export function useEmbeddingsReindexProgress(
revalidateOnFocus: boolean = true,
): {
payload: EmbeddingsReindexProgressType;
} {
const {
value: { payload },
send: sendCommand,
} = useWs("embeddings_reindex_progress", "embeddingsReindexProgress");
const data = useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
useEffect(() => {
let listener = undefined;
if (revalidateOnFocus) {
sendCommand("embeddingsReindexProgress");
listener = () => {
if (document.visibilityState == "visible") {
sendCommand("embeddingsReindexProgress");
}
};
addEventListener("visibilitychange", listener);
}
return () => {
if (listener) {
removeEventListener("visibilitychange", listener);
}
};
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revalidateOnFocus]);
return { payload: data };
}
export function useAudioTranscriptionProcessState(
revalidateOnFocus: boolean = true,
): { payload: string } {
const {
value: { payload },
send: sendCommand,
} = useWs("audio_transcription_state", "audioTranscriptionState");
const data = useMemo(
() => (payload ? (JSON.parse(payload as string) as string) : "idle"),
[payload],
);
useEffect(() => {
let listener = undefined;
if (revalidateOnFocus) {
sendCommand("audioTranscriptionState");
listener = () => {
if (document.visibilityState == "visible") {
sendCommand("audioTranscriptionState");
}
};
addEventListener("visibilitychange", listener);
}
return () => {
if (listener) {
removeEventListener("visibilitychange", listener);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revalidateOnFocus]);
return { payload: data || "idle" };
}
export function useBirdseyeLayout(revalidateOnFocus: boolean = true): {
payload: string;
} {
const {
value: { payload },
send: sendCommand,
} = useWs("birdseye_layout", "birdseyeLayout");
const data = useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
useEffect(() => {
let listener = undefined;
if (revalidateOnFocus) {
sendCommand("birdseyeLayout");
listener = () => {
if (document.visibilityState == "visible") {
sendCommand("birdseyeLayout");
}
};
addEventListener("visibilitychange", listener);
}
return () => {
if (listener) {
removeEventListener("visibilitychange", listener);
}
};
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revalidateOnFocus]);
return { payload: data };
}
export function useMotionActivity(camera: string): { payload: string } {
const {
value: { payload },
} = useWs(`${camera}/motion`, "");
return { payload: payload as string };
}
export function useAudioActivity(camera: string): { payload: number } {
const {
value: { payload },
} = useWs(`${camera}/audio/rms`, "");
return { payload: payload as number };
}
export function useAudioLiveTranscription(camera: string): {
payload: string;
} {
const {
value: { payload },
} = useWs(`${camera}/audio/transcription`, "");
return { payload: payload as string };
}
export function useMotionThreshold(camera: string): {
payload: string;
send: (payload: number, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/motion_threshold/state`,
`${camera}/motion_threshold/set`,
);
return { payload: payload as string, send };
}
export function useMotionContourArea(camera: string): {
payload: string;
send: (payload: number, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/motion_contour_area/state`,
`${camera}/motion_contour_area/set`,
);
return { payload: payload as string, send };
}
export function useImproveContrast(camera: string): {
payload: ToggleableSetting;
send: (payload: string, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/improve_contrast/state`,
`${camera}/improve_contrast/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function useTrackedObjectUpdate(): {
payload: TrackedObjectUpdateReturnType;
} {
const {
value: { payload },
} = useWs("tracked_object_update", "");
const parsed = useMemo(
() =>
payload
? JSON.parse(payload as string)
: { type: "", id: "", camera: "" },
[payload],
);
return { payload: parsed };
}
export function useNotifications(camera: string): {
payload: ToggleableSetting;
send: (payload: string, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/notifications/state`, `${camera}/notifications/set`);
return { payload: payload as ToggleableSetting, send };
}
export function useNotificationSuspend(camera: string): {
payload: string;
send: (payload: number, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/notifications/suspended`,
`${camera}/notifications/suspend`,
);
return { payload: payload as string, send };
}
export function useNotificationTest(): {
payload: string;
send: (payload: string, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs("notification_test", "notification_test");
return { payload: payload as string, send };
}
export function useTriggers(): { payload: TriggerStatus } {
const {
value: { payload },
} = useWs("triggers", "");
const parsed = useMemo(
() =>
payload
? JSON.parse(payload as string)
: { name: "", camera: "", event_id: "", type: "", score: 0 },
[payload],
);
return { payload: parsed };
}
export function useJobStatus<TResults = unknown>(
jobType: string,
revalidateOnFocus: boolean = true,
): { payload: Job<TResults> | null } {
const {
value: { payload },
send: sendCommand,
} = useWs("job_state", "jobState");
const jobData = useMemo(
() => (payload && typeof payload === "string" ? JSON.parse(payload) : {}),
[payload],
);
const currentJob = jobData[jobType] || null;
useEffect(() => {
let listener: (() => void) | undefined;
if (revalidateOnFocus) {
sendCommand("jobState");
listener = () => {
if (document.visibilityState === "visible") {
sendCommand("jobState");
}
};
addEventListener("visibilitychange", listener);
}
return () => {
if (listener) {
removeEventListener("visibilitychange", listener);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revalidateOnFocus]);
return { payload: currentJob as Job<TResults> | null };
}
export function useWsMessageSubscribe(callback: (msg: WsFeedMessage) => void) {
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
const handler = (msg: WsFeedMessage) => callbackRef.current(msg);
wsMessageSubscribers.add(handler);
return () => {
wsMessageSubscribers.delete(handler);
};
}, []);
}