From 928dbd833581d3eabae7d5115a2346a4dcfe27f6 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 29 Dec 2023 07:08:00 -0600 Subject: [PATCH] Update dashboard cameras dynamically (#9100) * Automatically update camera image when detecting objects and show activity indicators * Update ws typing * Cleanup type --- web/src/api/ws.tsx | 58 ++++++++-- .../components/camera/DynamicCameraImage.tsx | 106 ++++++++++++++++++ web/src/pages/Dashboard.tsx | 10 +- web/src/types/ws.ts | 34 ++++++ 4 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 web/src/components/camera/DynamicCameraImage.tsx create mode 100644 web/src/types/ws.ts diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 3f1cd87e6..eab7206d1 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -10,18 +10,19 @@ import { import { produce, Draft } from "immer"; import useWebSocket, { ReadyState } from "react-use-websocket"; import { FrigateConfig } from "@/types/frigateConfig"; +import { FrigateEvent } from "@/types/ws"; type ReducerState = { [topic: string]: { lastUpdate: number; - payload: string; + payload: any; retain: boolean; }; }; type ReducerAction = { topic: string; - payload: string; + payload: any; retain: boolean; }; @@ -132,7 +133,7 @@ export function useWs(watchTopic: string, publishTopic: string) { const value = state[watchTopic] || { payload: null }; const send = useCallback( - (payload: string, retain = false) => { + (payload: any, retain = false) => { if (readyState === ReadyState.OPEN) { sendJsonMessage({ topic: publishTopic || watchTopic, @@ -147,7 +148,10 @@ export function useWs(watchTopic: string, publishTopic: string) { return { value, send }; } -export function useDetectState(camera: string) { +export function useDetectState(camera: string): { + payload: string; + send: (payload: string, retain?: boolean) => void; +} { const { value: { payload }, send, @@ -155,7 +159,10 @@ export function useDetectState(camera: string) { return { payload, send }; } -export function useRecordingsState(camera: string) { +export function useRecordingsState(camera: string): { + payload: string; + send: (payload: string, retain?: boolean) => void; +} { const { value: { payload }, send, @@ -163,7 +170,10 @@ export function useRecordingsState(camera: string) { return { payload, send }; } -export function useSnapshotsState(camera: string) { +export function useSnapshotsState(camera: string): { + payload: string; + send: (payload: string, retain?: boolean) => void; +} { const { value: { payload }, send, @@ -171,7 +181,10 @@ export function useSnapshotsState(camera: string) { return { payload, send }; } -export function useAudioState(camera: string) { +export function useAudioState(camera: string): { + payload: string; + send: (payload: string, retain?: boolean) => void; +} { const { value: { payload }, send, @@ -179,7 +192,10 @@ export function useAudioState(camera: string) { return { payload, send }; } -export function usePtzCommand(camera: string) { +export function usePtzCommand(camera: string): { + payload: string; + send: (payload: string, retain?: boolean) => void; +} { const { value: { payload }, send, @@ -187,10 +203,34 @@ export function usePtzCommand(camera: string) { return { payload, send }; } -export function useRestart() { +export function useRestart(): { + payload: string; + send: (payload: string, retain?: boolean) => void; +} { const { value: { payload }, send, } = useWs("restart", "restart"); return { payload, send }; } + +export function useFrigateEvents(): { payload: FrigateEvent } { + const { + value: { payload }, + } = useWs(`events`, ""); + return { payload }; +} + +export function useMotionActivity(camera: string): { payload: string } { + const { + value: { payload }, + } = useWs(`${camera}/motion`, ""); + return { payload }; +} + +export function useAudioActivity(camera: string): { payload: string } { + const { + value: { payload }, + } = useWs(`${camera}/audio/rms`, ""); + return { payload }; +} diff --git a/web/src/components/camera/DynamicCameraImage.tsx b/web/src/components/camera/DynamicCameraImage.tsx new file mode 100644 index 000000000..875a21e99 --- /dev/null +++ b/web/src/components/camera/DynamicCameraImage.tsx @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useState } from "react"; +import { AspectRatio } from "../ui/aspect-ratio"; +import CameraImage from "./CameraImage"; +import { LuEar } from "react-icons/lu"; +import { CameraConfig } from "@/types/frigateConfig"; +import { TbUserScan } from "react-icons/tb"; +import { MdLeakAdd } from "react-icons/md"; +import { useFrigateEvents, useMotionActivity } from "@/api/ws"; + +type DynamicCameraImageProps = { + camera: CameraConfig; + aspect: number; +}; + +const INTERVAL_INACTIVE_MS = 60000; // refresh once a minute +const INTERVAL_ACTIVE_MS = 1000; // refresh once a second + +export default function DynamicCameraImage({ + camera, + aspect, +}: DynamicCameraImageProps) { + const [key, setKey] = useState(Date.now()); + const [activeObjects, setActiveObjects] = useState([]); + + const { payload: detectingMotion } = useMotionActivity(camera.name); + const { payload: event } = useFrigateEvents(); + const { payload: audioRms } = useMotionActivity(camera.name); + + useEffect(() => { + if (!event) { + return; + } + + if (event.after.camera != camera.name) { + return; + } + + if (event.type == "end") { + const eventIndex = activeObjects.indexOf(event.after.id); + + if (eventIndex != -1) { + const newActiveObjects = [...activeObjects]; + newActiveObjects.splice(eventIndex, 1); + setActiveObjects(newActiveObjects); + } + } else { + if (!event.after.stationary) { + const eventIndex = activeObjects.indexOf(event.after.id); + + if (eventIndex == -1) { + const newActiveObjects = [...activeObjects, event.after.id]; + setActiveObjects(newActiveObjects); + setKey(Date.now()); + } + } + } + }, [event, activeObjects]); + + const handleLoad = useCallback(() => { + const loadTime = Date.now() - key; + const loadInterval = + activeObjects.length > 0 ? INTERVAL_ACTIVE_MS : INTERVAL_INACTIVE_MS; + + setTimeout( + () => { + setKey(Date.now()); + }, + loadTime > loadInterval ? 1 : loadInterval + ); + }, [activeObjects, key]); + + return ( + + +
+ + 0 ? "text-cyan-500" : "text-gray-600" + }`} + /> + {camera.audio.enabled && ( + = camera.audio.min_volume + ? "text-orange-500" + : "text-gray-600" + }`} + /> + )} +
+
+ ); +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 7ad79bb94..12abc3264 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -10,8 +10,6 @@ import useSWR from "swr"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import Heading from "@/components/ui/heading"; import { Card } from "@/components/ui/card"; -import CameraImage from "@/components/camera/CameraImage"; -import { AspectRatio } from "@/components/ui/aspect-ratio"; import { Button } from "@/components/ui/button"; import { AiOutlinePicture } from "react-icons/ai"; import { FaWalking } from "react-icons/fa"; @@ -20,6 +18,7 @@ import { TbMovie } from "react-icons/tb"; import MiniEventCard from "@/components/card/MiniEventCard"; import { Event as FrigateEvent } from "@/types/event"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import DynamicCameraImage from "@/components/camera/DynamicCameraImage"; export function Dashboard() { const { data: config } = useSWR("config"); @@ -96,12 +95,7 @@ function Camera({ camera }: { camera: CameraConfig }) { <> - - - +
{camera.name.replaceAll("_", " ")} diff --git a/web/src/types/ws.ts b/web/src/types/ws.ts new file mode 100644 index 000000000..74682e86c --- /dev/null +++ b/web/src/types/ws.ts @@ -0,0 +1,34 @@ +type FrigateObjectState = { + id: string; + camera: string; + frame_time: number; + snapshot_time: number; + label: string; + sub_label: string | null; + top_score: number; + false_positive: boolean; + start_time: number; + end_time: number | null; + score: number; + box: [number, number, number, number]; + area: number; + ratio: number; + region: [number, number, number, number]; + current_zones: string[]; + entered_zones: string[]; + thumbnail: string | null; + has_snapshot: boolean; + has_clip: boolean; + stationary: boolean; + motionless_count: number; + position_changes: number; + attributes: { + [key: string]: number; + }; +}; + +export interface FrigateEvent { + type: "new" | "update" | "end"; + before: FrigateObjectState; + after: FrigateObjectState; +}