From 13809d384a99b7c25fe07c039b281b1a47aab4bd Mon Sep 17 00:00:00 2001 From: brandonhs Date: Wed, 7 Oct 2026 14:06:58 -0700 Subject: [PATCH 1/9] feat(cameras): Add frontend and initial backend communication for interval recording --- backend_py/src/models/cameras.py | 4 + backend_py/src/models/saved_cameras.py | 2 + .../src/services/cameras/device_manager.py | 4 +- .../src/services/cameras/drivers/device.py | 6 +- .../services/cameras/stream_engines/stream.py | 2 + .../dwe/cameras/stream/interval-recording.tsx | 112 ++++++++++++ .../components/dwe/cameras/stream/stream.tsx | 13 +- frontend/src/schemas/dwe_os_2.d.ts | 168 +++++++++++++++++- frontend/src/store/devices.ts | 2 + 9 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/dwe/cameras/stream/interval-recording.tsx diff --git a/backend_py/src/models/cameras.py b/backend_py/src/models/cameras.py index b1db3d5b..fe3315fd 100644 --- a/backend_py/src/models/cameras.py +++ b/backend_py/src/models/cameras.py @@ -161,6 +161,8 @@ class StreamModel(BaseModel): height: int interval: IntervalModel enabled: bool + recording_interval: int + recording_length: int class Config: from_attributes = True @@ -230,6 +232,8 @@ class StreamInfoModel(BaseModel): encode_type: StreamEncodeTypeEnum enabled: bool endpoints: list[StreamEndpointModel] + recording_interval: int + recording_length: int class Config: from_attributes = True diff --git a/backend_py/src/models/saved_cameras.py b/backend_py/src/models/saved_cameras.py index 8f94b735..a98d5450 100644 --- a/backend_py/src/models/saved_cameras.py +++ b/backend_py/src/models/saved_cameras.py @@ -35,6 +35,8 @@ class SavedStreamModel(BaseModel): height: int interval: IntervalModel enabled: bool + recording_interval: int + recording_length: int class Config: # use_enum_values = True diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index 53cf8d1a..59e096a1 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -218,9 +218,11 @@ def configure_device_stream(self, stream_info: StreamInfoModel) -> bool: encode_type: StreamEncodeTypeEnum = stream_info.encode_type stream_type: StreamTypeEnum = stream_info.stream_type endpoints = stream_info.endpoints + recording_interval = stream_info.recording_interval + recording_length = stream_info.recording_length device.configure_stream( - encode_type, width, height, interval, stream_type, endpoints + encode_type, width, height, interval, stream_type, recording_interval, recording_length, endpoints ) if stream_info.enabled: diff --git a/backend_py/src/services/cameras/drivers/device.py b/backend_py/src/services/cameras/drivers/device.py index 25043d9d..8c2ad3a0 100644 --- a/backend_py/src/services/cameras/drivers/device.py +++ b/backend_py/src/services/cameras/drivers/device.py @@ -216,6 +216,8 @@ def configure_stream( height: int, interval: IntervalModel, stream_type: StreamTypeEnum, + recording_interval: int, + recording_length: int, stream_endpoints: list[StreamEndpointModel] | None = None, ) -> None: if stream_endpoints is None: @@ -314,7 +316,9 @@ def load_settings(self, saved_device: SavedDeviceModel) -> None: saved_device.stream.height, saved_device.stream.interval, saved_device.stream.stream_type, - saved_device.stream.endpoints, + saved_device.stream.recording_interval, + saved_device.stream.recording_length, + saved_device.stream.endpoints ) with self._configuration_lock: diff --git a/backend_py/src/services/cameras/stream_engines/stream.py b/backend_py/src/services/cameras/stream_engines/stream.py index 77c98aa1..284368d2 100644 --- a/backend_py/src/services/cameras/stream_engines/stream.py +++ b/backend_py/src/services/cameras/stream_engines/stream.py @@ -26,6 +26,8 @@ class Stream: default_factory=lambda: IntervalModel(numerator=1, denominator=30) ) enabled: bool = False + recording_interval: int = 0 + recording_length: int = 0 # Configuration specific software_h264_bitrate: int = 5000 diff --git a/frontend/src/components/dwe/cameras/stream/interval-recording.tsx b/frontend/src/components/dwe/cameras/stream/interval-recording.tsx new file mode 100644 index 00000000..f13e7d3e --- /dev/null +++ b/frontend/src/components/dwe/cameras/stream/interval-recording.tsx @@ -0,0 +1,112 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useDeviceStore } from "@/store/devices"; +import { useEffect, useState } from "react"; + +export const IntervalRecording = ({ + bus_id, + disabled, + onChange, +}: { + bus_id: string; + disabled: boolean; + onChange: (recording_interval: number, recording_length: number) => void; +}) => { + const isStreamLoading = useDeviceStore( + (state) => state.isStreamLoading[bus_id] ?? false, + ); + + const [enabled, setEnabled] = useState(true); + const [intervalMinutes, setIntervalMinutes] = useState(10); + const [durationMinutes, setDurationMinutes] = useState(1); + + const isDisabled = disabled || isStreamLoading; + const isValid = + intervalMinutes > 0 && + durationMinutes > 0 && + durationMinutes < intervalMinutes; + + const parseMinutes = (value: string) => Math.max(0, parseInt(value) || 0); + + return ( + + + + Interval Recording + + +
+
+ {/**/} + {/**/} +
+ +
+
+ + + setIntervalMinutes(parseMinutes(e.target.value)) + } + onBlur={() => onChange(intervalMinutes, durationMinutes)} + /> +
+ +
+ + + setDurationMinutes(parseMinutes(e.target.value)) + } + onBlur={() => onChange(intervalMinutes, durationMinutes)} + /> +
+
+ + {isValid ? ( +
+ Records for {durationMinutes} minute + {durationMinutes === 1 ? "" : "s"} every {intervalMinutes}{" "} + minute{intervalMinutes === 1 ? "" : "s"}. +
+ ) : ( +
+ Recording duration must be greater than 0 and shorter than the + interval. +
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/components/dwe/cameras/stream/stream.tsx b/frontend/src/components/dwe/cameras/stream/stream.tsx index f8a402e0..164c33e5 100644 --- a/frontend/src/components/dwe/cameras/stream/stream.tsx +++ b/frontend/src/components/dwe/cameras/stream/stream.tsx @@ -22,6 +22,7 @@ import { import { useCallback, useEffect, useState } from "react"; import { components } from "@/schemas/dwe_os_2"; import { FollowerList } from "./follower-list"; +import { IntervalRecording } from "./interval-recording"; import { CameraControls } from "../camera-controls"; import { toast } from "sonner"; @@ -138,10 +139,12 @@ export const CameraStream = ({ bus_id }: { bus_id: string }) => { width: device.stream.width, height: device.stream.height, interval: { - numerator: 1, + numerator: 1, denominator: parseInt(newFps), }, }, + recording_interval: 0, + recording_length: 0 }); }} /> @@ -196,6 +199,14 @@ export const CameraStream = ({ bus_id }: { bus_id: string }) => { {canLead(device) && } + {device.stream.stream_type === "RECORDING" && ( + { + configureStream(device.bus_info, { + recording_interval, recording_length + }); + }} /> + )} +
- {isValid ? ( + {isContinuous ? ( +
+ Records continuously. Set both values above 0 to record on an + interval. +
+ ) : isValid ? (
Records for {durationSeconds} second {durationSeconds === 1 ? "" : "s"} every {intervalSeconds}{" "} @@ -98,8 +120,7 @@ export const IntervalRecording = ({
) : (
- Recording duration must be greater than 0 and shorter than the - interval. + Recording duration must be shorter than the interval.
)} diff --git a/frontend/src/store/devices.ts b/frontend/src/store/devices.ts index 06883a39..6c0b037f 100644 --- a/frontend/src/store/devices.ts +++ b/frontend/src/store/devices.ts @@ -79,8 +79,14 @@ export const useDeviceStore = create()( interval: stream.interval, }, stream_type: streamType, - recording_interval: partialStreamInfo.recording_interval ?? stream.recording_interval, - recording_length: partialStreamInfo.recording_length ?? stream.recording_length + // Devices saved before interval recording existed may not have these, + // so fall back to 0 (record normally) instead of omitting them + recording_interval: + partialStreamInfo.recording_interval ?? + stream.recording_interval ?? + 0, + recording_length: + partialStreamInfo.recording_length ?? stream.recording_length ?? 0, }; set((state) => { From 7f84e0e598cc6bf4984ea3362c304bdbcdc04608 Mon Sep 17 00:00:00 2001 From: brandonhs Date: Mon, 12 Oct 2026 13:40:18 -0700 Subject: [PATCH 9/9] fix(cameras): Fix issue with stopping recordings that are not in interval; remove light control --- backend_py/src/services/cameras/device_manager.py | 10 ++++++---- .../cameras/stream_engines/gstreamer_stream_engine.py | 2 +- .../src/services/recordings/recordings_service.py | 5 +++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/backend_py/src/services/cameras/device_manager.py b/backend_py/src/services/cameras/device_manager.py index ac94e4bb..ab1c0a9e 100644 --- a/backend_py/src/services/cameras/device_manager.py +++ b/backend_py/src/services/cameras/device_manager.py @@ -149,10 +149,12 @@ def create_device(self, device_info: DeviceInfo) -> Device | None: lambda _: self._append_stream_error(DeviceModel.model_validate(device)), ) - if not self.serial_mode_fps: - device.stream_runner.on("stream_active", lambda: self._set_light(True)) - - device.stream_runner.on("stream_inactive", lambda: self._set_light(False)) + # if not self.serial_mode_fps: + # device.stream_runner.on("stream_active", + # lambda: self._set_light(True)) + # + # device.stream_runner.on("stream_inactive", + # lambda: self._set_light(False)) # Hack to allow shd to save follower and leader settings on removal device.on("save", lambda: self.settings_manager.save_device(device)) diff --git a/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py b/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py index 835a9624..138de6db 100644 --- a/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py +++ b/backend_py/src/services/cameras/stream_engines/gstreamer_stream_engine.py @@ -253,7 +253,7 @@ def _terminate_process(self) -> None: def stop(self) -> None: with self._lock: - if not self.started or not self._process: + if not self.started: return self.logger.info("Stopping stream") diff --git a/backend_py/src/services/recordings/recordings_service.py b/backend_py/src/services/recordings/recordings_service.py index 6303c744..369007e6 100644 --- a/backend_py/src/services/recordings/recordings_service.py +++ b/backend_py/src/services/recordings/recordings_service.py @@ -120,8 +120,9 @@ def _get_duration(self, file_path: str) -> str: self.logger.error(f"exiftool was not found: {e}") except json.JSONDecodeError as e: self.logger.error(f"Error decoding output from exiftool: {e}") - except subprocess.CalledProcessError as e: - self.logger.warning(f"exiftool had an unknown system error: {e}") + except subprocess.CalledProcessError: + # self.logger.warning(f"exiftool had an unknown system error: {e}") + pass return "Unknown" def get_recording(self, filename: str) -> RecordingInfo | None: