diff --git a/server/src/app.ts b/server/src/app.ts index 179a70b..79c2840 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,7 +6,7 @@ import { openDb, getSetting, setSetting, deleteSetting, type DB } from "./db.ts" import { Bus } from "./bus.ts"; import { Auth } from "./auth.ts"; import { SettingsStore, type Settings } from "./settings.ts"; -import { CameraStore, cameraJson, go2rtcSources, isPush, type CameraRecord, type CameraStatus, type CameraJson } from "./cameras.ts"; +import { CameraStore, cameraJson, go2rtcSources, isPush, recordsOnEvent, type CameraRecord, type CameraStatus, type CameraJson } from "./cameras.ts"; import { Recordings, type RecordingRow } from "./recordings.ts"; import { Push } from "./push.ts"; import { Go2rtc, codecSummary, freePort, type StreamDef } from "./go2rtc.ts"; @@ -28,6 +28,9 @@ import { HttpError, newId, randomToken, sha256 } from "./util.ts"; import { run } from "./proc.ts"; import { EufyBridge } from "./eufy.ts"; +const EUFY_CLIP_QUIET_SEC = 20; +const EUFY_CLIP_MAX_SEC = 180; + const log = logger("app"); type GatewayLink = { url: string; siteId: string; secret: string; siteName?: string; enabled: boolean }; @@ -248,7 +251,7 @@ export class App { }); } const ready = this.go2rtc.running; - this.recorder.sync(cams, ready); + this.syncRecorder(cams, ready); this.motion.sync( cams.filter((c) => c.kind !== "eufy"), ready, @@ -557,23 +560,40 @@ export class App { private eufyMotionTimers = new Map>(); private eufyWakes = new Map(); + private eufyClips = new Map(); + + private syncRecorder(cams = this.cameras.list(), ready = this.go2rtc.running) { + this.recorder.sync( + cams.filter((c) => !recordsOnEvent(c) || this.eufyClips.has(c.id)), + ready, + ); + } private onEufyMotion(serial: string, at: number) { const cam = this.cameras.list().find((c) => c.kind === "eufy" && c.fields.serial === serial && c.enabled && c.motionEnabled); if (!cam) return; - const quietMs = Math.max(10, this.settings.get().recording.postMotionSec) * 1000; + const clip = recordsOnEvent(cam); + const quietMs = Math.max(clip ? EUFY_CLIP_QUIET_SEC : 10, this.settings.get().recording.postMotionSec) * 1000; const t = this.eufyMotionTimers.get(cam.id); + const clipStart = this.eufyClips.get(cam.id); + if (t && clipStart !== undefined && at - clipStart > EUFY_CLIP_MAX_SEC * 1000) return; if (t) clearTimeout(t); - else + else { this.onMotionStart(cam, at, 100, async () => { await Bun.sleep(4000); return this.eufy.snapshot(serial); }); + if (clip) { + this.eufyClips.set(cam.id, at); + this.syncRecorder(); + } + } this.eufyMotionTimers.set( cam.id, setTimeout(() => { this.eufyMotionTimers.delete(cam.id); this.onMotionEnd(cam, at, Date.now(), 100); + if (this.eufyClips.delete(cam.id)) this.syncRecorder(); }, quietMs), ); } diff --git a/server/src/cameras.ts b/server/src/cameras.ts index 59d7b88..e81a1be 100644 --- a/server/src/cameras.ts +++ b/server/src/cameras.ts @@ -100,6 +100,10 @@ function fromRow(r: CameraRow): CameraRecord { }; } +export function recordsOnEvent(c: Pick): boolean { + return c.kind === "eufy" && c.recordingMode === "motion"; +} + export function isPush(kind: SourceKind): boolean { return kind === "rtsp-push" || kind === "rtmp-push"; } diff --git a/server/test/misc.test.ts b/server/test/misc.test.ts index 46b3c0c..49e7cbc 100644 --- a/server/test/misc.test.ts +++ b/server/test/misc.test.ts @@ -9,6 +9,7 @@ import { parseProbeMatch, scopeValue, guessBrand } from "../src/discovery.ts"; import { rcloneToken } from "../src/storage/gdrive.ts"; import { redact } from "../src/log.ts"; import { matchParts } from "../src/http/router.ts"; +import { recordsOnEvent } from "../src/cameras.ts"; import { createHash } from "node:crypto"; describe("demo", () => { @@ -116,6 +117,11 @@ describe("onvif and discovery", () => { }); describe("misc", () => { + test("eufy cameras in motion mode record on events only", () => { + expect(recordsOnEvent({ kind: "eufy", recordingMode: "motion" })).toBe(true); + expect(recordsOnEvent({ kind: "eufy", recordingMode: "continuous" })).toBe(false); + expect(recordsOnEvent({ kind: "rtsp", recordingMode: "motion" })).toBe(false); + }); test("rclone token format", () => { const t = JSON.parse(rcloneToken({ access_token: "a", refresh_token: "r", expires_in: 3600 })); expect(t).toMatchObject({ access_token: "a", refresh_token: "r", token_type: "Bearer" }); diff --git a/server/web/src/pages/Cameras.tsx b/server/web/src/pages/Cameras.tsx index 4b44e0d..e28d49b 100644 --- a/server/web/src/pages/Cameras.tsx +++ b/server/web/src/pages/Cameras.tsx @@ -355,8 +355,8 @@ function CameraEditor({ camera, brand, onClose }: { camera: Camera; brand?: Bran
{( [ - ['continuous', 'Continuous', 'Record around the clock.'], - ['motion', 'Motion only', 'Save clips when something moves.'], + ['continuous', 'Continuous', camera.source.kind === 'eufy' ? 'Keeps the camera awake. Drains a battery quickly.' : 'Record around the clock.'], + ['motion', 'Motion only', camera.source.kind === 'eufy' ? 'Wakes the camera and saves a clip when eufy reports motion.' : 'Save clips when something moves.'], ['off', 'Off', 'Live view only, nothing is stored.'], ] as [RecordingMode, string, string][] ).map(([v, t, d]) => (