From a4506fe3073d8693958895c68a6ae9714718edf9 Mon Sep 17 00:00:00 2001 From: Josh Anon Date: Sun, 31 May 2026 15:07:57 -0700 Subject: [PATCH 1/3] Reliability for shared-HomeBase (battery) cameras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Eufy HomeBase serves only ONE camera P2P stream at a time; opening the Home app grid (a live request per tile) plus a tap fires several startLivestreams at once, they mutually starve, and each camera's wedge logic independently recycles the shared station — a cascading "wedge". This is a set of focused fixes for that class of problem on HomeBase-mediated battery cameras. - Per-HomeBase single-stream coordinator (utils/station-stream-coordinator): serialize P2P starts per station (concurrency 1); live preempts, background thumbnail refresh is denied while busy; clean newest-tap-wins handoff that stops the old camera's P2P before the new one starts (no overlap/starve). 4G LTE cameras are their own station. - Snapshot/thumbnail cache that never wakes the camera: serve the last keyframe as the tile image (any age) and return a neutral placeholder on a true miss instead of throwing (throwing made the Snapshot plugin fall back to the waking video path). Persisted across reloads. Optional per-camera background refresh. - Cold-start: recycle a wedged/idle station P2P within the viewer's ~30s patience window (no-data threshold 45s -> 18s) instead of after it has quit. - Audio-aware fMP4 muxing: pick JMuxer both vs video mode by whether the camera delivers usable (ADTS) audio, so mic-off / config-packet-only cameras don't hang the muxer (which left live view black); backstop rebuilds a stalled `both` muxer video-only after 4s. - Report the true source codec to Scrypted so an H.265 stream isn't relabeled H.264 and `-vcodec copy`'d as-is. - Recycle-suppression guard so a signal-dead camera can't keep recycling the shared HomeBase and disrupting its healthy siblings. Unit tested across the stream server, coordinator, snapshot service, and thumbnail refresh. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../eufy-security-scrypted/src/eufy-device.ts | 552 ++++++++- .../src/services/device/snapshot-service.ts | 94 +- .../src/services/device/stream-service.ts | 7 +- .../src/services/device/types.ts | 11 + .../src/utils/recycle-guard.ts | 60 + .../src/utils/station-stream-coordinator.ts | 225 ++++ .../src/utils/thumbnail-refresh.ts | 94 ++ .../tests/unit/eufy-device-intercom.test.ts | 22 + .../tests/unit/eufy-device-ptz.test.ts | 24 + .../unit/services/snapshot-service.test.ts | 95 +- .../unit/services/stream-service.test.ts | 24 + .../tests/unit/utils/recycle-guard.test.ts | 52 + .../utils/station-stream-coordinator.test.ts | 172 +++ .../unit/utils/thumbnail-refresh.test.ts | 85 ++ packages/eufy-stream-server/src/jmuxer.d.ts | 7 + .../eufy-stream-server/src/stream-server.ts | 1054 +++++++++++++++-- .../tests/stream-server.test.ts | 639 +++++++++- 17 files changed, 3061 insertions(+), 156 deletions(-) create mode 100644 packages/eufy-security-scrypted/src/utils/recycle-guard.ts create mode 100644 packages/eufy-security-scrypted/src/utils/station-stream-coordinator.ts create mode 100644 packages/eufy-security-scrypted/src/utils/thumbnail-refresh.ts create mode 100644 packages/eufy-security-scrypted/tests/unit/utils/recycle-guard.test.ts create mode 100644 packages/eufy-security-scrypted/tests/unit/utils/station-stream-coordinator.test.ts create mode 100644 packages/eufy-security-scrypted/tests/unit/utils/thumbnail-refresh.test.ts diff --git a/packages/eufy-security-scrypted/src/eufy-device.ts b/packages/eufy-security-scrypted/src/eufy-device.ts index f8842ea..e51e282 100644 --- a/packages/eufy-security-scrypted/src/eufy-device.ts +++ b/packages/eufy-security-scrypted/src/eufy-device.ts @@ -70,6 +70,7 @@ import { EVENT_SOURCES, EufyWebSocketClient, EventCallbackForType, + STATION_EVENTS, } from "@caplaz/eufy-security-client"; import { Logger, ILogObj } from "tslog"; @@ -86,6 +87,24 @@ import { StateChangeEvent, } from "./services/device"; import { PropertyMapper } from "./utils/property-mapper"; +import { + acquireStationSlot, + isStationSlotHeldByOther, + otherDeviceDeliveringOnStation, +} from "./utils/station-stream-coordinator"; +import { + recycleSuppression, + RECYCLE_SUPPRESS_MS, +} from "./utils/recycle-guard"; +import { + shouldRefreshThumbnail, + nextRefreshBackoffMs, + resolveRefreshChoice, + THUMBNAIL_REFRESH_CHOICES, + THUMBNAIL_REFRESH_DEFAULT_CHOICE, +} from "./utils/thumbnail-refresh"; + +const THUMBNAIL_REFRESH_SETTING_KEY = "thumbnailRefreshInterval"; import { VideoClipsService } from "./services/video"; import { PtzControlService, LightControlService } from "./services/control"; @@ -140,6 +159,35 @@ export class EufyDevice private talkbackActive = false; private intercomStartedLivestream = false; + // Station P2P recycle bookkeeping. When the stream server reports the + // upstream as wedged (startLivestream acked but no LIVESTREAM_VIDEO_DATA + // events), we attempt to recycle the bropat-side station P2P session + // via station.disconnect()/connect(). Rate-limited to avoid storms — a + // wedge that survives recycling means the problem is deeper than the + // bropat client (eufy-security-ws process, network, or Eufy's relay). + private lastStationRecycleAt = 0; + private stationRecycleInFlight = false; + private readonly MIN_STATION_RECYCLE_INTERVAL_MS = 5 * 60 * 1000; + // Chronic-failure guard: a camera that can't stream (no signal / dead) + // shouldn't keep recycling the shared HomeBase and disrupting its healthy + // siblings. Count recycles that didn't recover us; once over the cap (or if + // we report no signal), suppress recycles until `recycleSuppressedUntil`. + // Reset when video actually flows (livestreamActive). + private consecutiveFailedRecycles = 0; + private recycleSuppressedUntil = 0; + + // Background thumbnail refresh bookkeeping. A timer periodically wakes this + // camera (at background priority, gated by the station coordinator) to keep + // its cached thumbnail reasonably fresh — but only when the HomeBase slot is + // free and the cache is stale, with exponential backoff for cameras that + // never deliver video. + private thumbnailRefreshInterval?: ReturnType; + private thumbnailRefreshKick?: ReturnType; + private consecutiveRefreshFailures = 0; + private refreshBackoffUntil = 0; + private readonly THUMBNAIL_REFRESH_CHECK_MS = 5 * 60 * 1000; // check every 5 min + private readonly THUMBNAIL_REFRESH_CAPTURE_TIMEOUT_MS = 55 * 1000; + // Device info and state private latestProperties?: DeviceProperties; private propertiesLoaded: Promise; @@ -406,11 +454,30 @@ export class EufyDevice await this.propertiesLoaded; // Delegate to settings service - return this.settingsService.getSettings( + const settings = this.settingsService.getSettings( this.info! as any, this.latestProperties!, this.name || "Unknown Device", ); + + // Per-camera background thumbnail refresh interval (battery vs freshness). + settings.push({ + key: THUMBNAIL_REFRESH_SETTING_KEY, + title: "Background Thumbnail Refresh", + description: + "How stale this camera's grid thumbnail may get before it is briefly " + + "woken to refresh it. Wakes only when the camera is idle and the " + + "HomeBase is free — never interrupts live view or recording. Lower = " + + "fresher tiles but more battery; choose Off or a long interval for " + + "battery/LTE cameras.", + value: + (this.storage.getItem(THUMBNAIL_REFRESH_SETTING_KEY) as string) || + THUMBNAIL_REFRESH_DEFAULT_CHOICE, + choices: Object.keys(THUMBNAIL_REFRESH_CHOICES), + group: "Streaming", + }); + + return settings; } /** @@ -418,6 +485,13 @@ export class EufyDevice * Delegates to the settings service for property updates and custom settings */ async putSetting(key: string, value: SettingValue): Promise { + // Per-camera thumbnail refresh interval — stored locally, not a device prop. + if (key === THUMBNAIL_REFRESH_SETTING_KEY) { + this.storage.setItem(key, String(value)); + this.logger.info(`🖼️ Background thumbnail refresh set to: ${value}`); + return; + } + // Callback to handle successful property updates const onSuccess = (settingKey: string, settingValue: SettingValue) => { // Update local properties if it's a device property @@ -822,19 +896,491 @@ export class EufyDevice * Stream server lifecycle is now managed by StreamService. */ private createStreamServer(): void { + // Load the last-detected codec from Scrypted device storage so the + // very first `getVideoStream()` call after a plugin reload advertises + // the correct codec. Without this hint, downstream consumers (the + // Rebroadcast plugin, in particular) set up sync-frame detection for + // H.264 NAL types and never find a keyframe in an H.265 stream — + // HomeKit's transcoder then sees "Unable to find sync frame in rtsp + // prebuffer" and the session dies at the 30s timeout. + const storedCodec = this.storage.getItem( + "lastDetectedVideoCodec", + ) as "H264" | "H265" | null; + const initialVideoCodec = + storedCodec === "H264" || storedCodec === "H265" + ? storedCodec + : undefined; + if (initialVideoCodec) { + this.logger.info( + `🎬 Using stored codec hint for stream server: ${initialVideoCodec}`, + ); + } + this.streamServer = new StreamServer({ port: 0, // Let the system assign a free port host: "127.0.0.1", // Only allow connections from localhost logger: this.logger, // Pass tslog Logger directly wsClient: this.wsClient, serialNumber: this.serialNumber, + initialVideoCodec, + // Serialize streaming across cameras on the same HomeBase (one P2P + // stream at a time). Live always wins (preempting); background + // (thumbnail refresh) is denied while the slot is busy. + acquireStreamSlot: (priority, onRevoke) => + acquireStationSlot( + this.getStationSN(), + this.serialNumber, + priority, + onRevoke, + ), }); + // Restore the last thumbnail keyframe from storage so the camera's tile + // shows its last-seen image immediately after a plugin reload — no wake, + // no post-restart refresh stampede. + this.restoreThumbnailKeyframe(); + + // Persist live-detected codec so the next plugin restart starts with + // the right hint. The event fires exactly once per stream-server + // instance (on the first video data event). + this.streamServer.on("metadataReceived", (metadata: { videoCodec?: string }) => { + const codec = metadata?.videoCodec?.toUpperCase(); + const normalized = codec === "H265" || codec === "HEVC" ? "H265" : "H264"; + const previous = this.storage.getItem("lastDetectedVideoCodec"); + if (previous !== normalized) { + this.storage.setItem("lastDetectedVideoCodec", normalized); + this.logger.info( + `💾 Persisted detected video codec: ${normalized} (was: ${previous ?? "unset"})`, + ); + } + }); + + this.streamServer.on( + "upstreamWedged", + (info: { + serialNumber: string; + reason: "cold-start-counter-maxed" | "data-flow-stale"; + attempts?: number; + staleMs?: number; + consumers?: number; + }) => { + this.recycleStationP2P(info).catch((e) => + this.logger.warn(`Station P2P recycle threw: ${e}`), + ); + }, + ); + + this.streamServer.on("livestreamActive", () => { + // We're streaming again — recycling (if any) worked. Clear the chronic- + // failure guard so a future genuine wedge gets its recovery chance. + // (The coordinator tracks "delivering" via the lease for the recycle + // guard's sibling check.) + this.consecutiveFailedRecycles = 0; + this.recycleSuppressedUntil = 0; + }); + this.streamServer.on("livestreamInactive", () => { + // The camera just stopped — save its last frame so the tile survives a + // reload. (Populated by any stream: live view, motion recording, etc.) + this.persistThumbnailKeyframe(); + }); + + this.startThumbnailRefresh(); + this.logger.debug( "Stream server created with WebSocket client integration", ); } + /** + * Recycle the upstream bropat-side P2P session for this device's station. + * + * Triggered when the stream server's circuit breaker concludes that the + * upstream is wedged (startLivestream is acked but no LIVESTREAM_VIDEO_DATA + * arrives). For 4G LTE cameras the device IS its own station, so this + * disconnects/reconnects just the one camera's P2P session. For + * HomeBase-attached cameras it affects every camera on that station. + * + * The recycle is rate-limited and serialized: if a recycle is already + * in flight or one ran within MIN_STATION_RECYCLE_INTERVAL_MS, we skip. + * The next consumer that attaches will trigger a fresh startLivestream + * organically — we deliberately don't auto-retry from here, so the + * outcome of the recycle is observable in the next consumer's lifecycle. + */ + /** + * Serial of the station (HomeBase) this device belongs to. 4G LTE cameras + * are their own station, so this falls back to the device serial. + */ + private getStationSN(): string { + return this.latestProperties?.stationSerialNumber || this.serialNumber; + } + + private static readonly THUMBNAIL_KEYFRAME_STORAGE_KEY = "lastThumbnailKeyframe"; + // Keyframes are small (compressed H.264/H.265, typically 10–110 KB). Cap to + // avoid bloating Scrypted's storage if a frame is unexpectedly large. + private static readonly MAX_PERSISTED_KEYFRAME_BYTES = 220 * 1024; + + /** Save the current cached keyframe to storage so the tile survives reload. */ + private persistThumbnailKeyframe(): void { + try { + const cached = this.streamServer?.getCachedKeyframe( + Number.POSITIVE_INFINITY, + ); + if (!cached) return; + if (cached.data.length > EufyDevice.MAX_PERSISTED_KEYFRAME_BYTES) return; + this.storage.setItem( + EufyDevice.THUMBNAIL_KEYFRAME_STORAGE_KEY, + JSON.stringify({ + data: cached.data.toString("base64"), + codec: cached.codec, + }), + ); + } catch (e) { + this.logger.debug(`Persisting thumbnail keyframe failed: ${e}`); + } + } + + /** Restore a persisted keyframe into the stream server's cache (no wake). */ + private restoreThumbnailKeyframe(): void { + try { + const raw = this.storage.getItem( + EufyDevice.THUMBNAIL_KEYFRAME_STORAGE_KEY, + ); + if (!raw) return; + const parsed = JSON.parse(raw) as { data?: string; codec?: string }; + if ( + parsed?.data && + (parsed.codec === "H264" || parsed.codec === "H265") + ) { + this.streamServer.setCachedKeyframe( + Buffer.from(parsed.data, "base64"), + parsed.codec, + ); + this.logger.info( + "🖼️ Restored last thumbnail from storage (no camera wake)", + ); + } + } catch (e) { + this.logger.debug(`Restoring thumbnail keyframe failed: ${e}`); + } + } + + /** + * Start the periodic background thumbnail refresh. Staggered per device so + * cameras on the same HomeBase don't all check at once. Each tick wakes the + * camera only if its cache is stale AND the HomeBase slot is free AND we're + * not in failure backoff — so it never competes with a live view/recording + * and never hammers a dead camera. + */ + private startThumbnailRefresh(): void { + // Deterministic per-device stagger across the check interval. + let hash = 0; + for (let i = 0; i < this.serialNumber.length; i++) { + hash = (hash * 31 + this.serialNumber.charCodeAt(i)) >>> 0; + } + const stagger = hash % this.THUMBNAIL_REFRESH_CHECK_MS; + + this.thumbnailRefreshKick = setTimeout(() => { + this.runThumbnailRefreshTick().catch(() => {}); + this.thumbnailRefreshInterval = setInterval(() => { + this.runThumbnailRefreshTick().catch(() => {}); + }, this.THUMBNAIL_REFRESH_CHECK_MS); + }, stagger); + } + + /** One background-refresh evaluation; wakes the camera only if warranted. */ + private async runThumbnailRefreshTick(): Promise { + if (!this.streamServer) return; + + // Per-camera interval (default 2h). "Off" disables the refresh entirely. + const thresholdMs = resolveRefreshChoice( + this.storage.getItem(THUMBNAIL_REFRESH_SETTING_KEY) as string | undefined, + ); + if (thresholdMs === null) return; + + const cached = this.streamServer.getCachedKeyframe(Number.POSITIVE_INFINITY); + const cacheAgeMs = cached ? cached.ageMs : null; + const slotBusy = isStationSlotHeldByOther( + this.getStationSN(), + this.serialNumber, + ); + const backoffRemainingMs = Math.max(0, this.refreshBackoffUntil - Date.now()); + + if ( + !shouldRefreshThumbnail({ + cacheAgeMs, + slotBusy, + backoffRemainingMs, + thresholdMs, + }) + ) { + return; + } + + this.logger.info( + `🖼️ Background thumbnail refresh (cache ${cacheAgeMs === null ? "empty" : Math.round(cacheAgeMs / 60000) + "min old"})`, + ); + try { + // Background-priority wake (the coordinator denies if the slot is taken). + // The captured keyframe is cached by the stream server for snapshots. + await this.streamServer.captureSnapshot( + this.THUMBNAIL_REFRESH_CAPTURE_TIMEOUT_MS, + ); + this.consecutiveRefreshFailures = 0; + this.refreshBackoffUntil = 0; + this.logger.info("🖼️ Thumbnail refreshed from background wake"); + } catch { + this.consecutiveRefreshFailures++; + const backoff = nextRefreshBackoffMs(this.consecutiveRefreshFailures); + this.refreshBackoffUntil = Date.now() + backoff; + this.logger.info( + `🖼️ Thumbnail refresh did not complete (#${this.consecutiveRefreshFailures}) — backing off ${Math.round(backoff / 60000)}min`, + ); + } + } + + private async recycleStationP2P(info: { + reason: "cold-start-counter-maxed" | "data-flow-stale"; + attempts?: number; + staleMs?: number; + consumers?: number; + }): Promise { + const now = Date.now(); + + // Guard A: recycles suppressed for this camera (chronic failure / no + // signal) — protect the healthy cameras on the shared HomeBase. + if (now < this.recycleSuppressedUntil) { + this.logger.warn( + `⏭️ Not recycling HomeBase for ${this.serialNumber} — suppressed ${Math.round((this.recycleSuppressedUntil - now) / 60000)} more min (camera not recovering; protecting siblings). Fix this camera's signal/power.`, + ); + return; + } + + if (this.stationRecycleInFlight) { + this.logger.info( + "⏭️ Skipping station P2P recycle — another recycle is already in flight", + ); + return; + } + const sinceLast = now - this.lastStationRecycleAt; + if ( + this.lastStationRecycleAt !== 0 && + sinceLast < this.MIN_STATION_RECYCLE_INTERVAL_MS + ) { + this.logger.warn( + `⏭️ Skipping station P2P recycle — last attempt was ${Math.round( + sinceLast / 1000, + )}s ago (min interval: ${Math.round( + this.MIN_STATION_RECYCLE_INTERVAL_MS / 1000, + )}s). Upstream wedge appears persistent across recycles — likely needs eufy-security-ws restart.`, + ); + return; + } + + const stationSN = this.getStationSN(); + const isSelfStation = stationSN === this.serialNumber; + + // Guard: a recycle disconnects/reconnects the whole HomeBase, which + // interrupts every camera on it. If a sibling on this station is + // actively delivering video, don't tear its session down — defer the + // recycle. We've already cleared our own livestream intent (in + // markUpstreamWedged), so the next consumer that attaches to this + // device will retry organically; by then the sibling may be idle. + // Self-station 4G cameras have no siblings, so they never defer. + if (!isSelfStation) { + const busySibling = otherDeviceDeliveringOnStation( + stationSN, + this.serialNumber, + ); + if (busySibling) { + this.logger.warn( + `⏭️ Deferring station P2P recycle for ${stationSN} — sibling ${busySibling} is actively streaming on this HomeBase. Will retry on next consumer attach.`, + ); + return; + } + } + + // Guards B/C: a camera that can't stream (no WiFi signal) or that hasn't + // recovered after a recycle shouldn't keep recycling the shared HomeBase + // and disrupting healthy siblings. Suppress and fail fast instead. + const suppression = recycleSuppression({ + isSelfStation, + signalLevel: this.latestProperties?.wifiSignalLevel, + consecutiveFailedRecycles: this.consecutiveFailedRecycles, + }); + if (suppression.suppress) { + this.recycleSuppressedUntil = now + RECYCLE_SUPPRESS_MS; + const why = + suppression.reason === "no-signal" + ? `reports no WiFi signal (level 0) — can't stream regardless` + : `still wedged after ${this.consecutiveFailedRecycles} recycle(s) without recovering`; + this.logger.warn( + `🚫 ${this.serialNumber} ${why}. Suppressing HomeBase (${stationSN}) recycles for ${Math.round(RECYCLE_SUPPRESS_MS / 60000)}min to protect sibling cameras. Fix this camera's signal/power.`, + ); + return; + } + // We're going ahead with a recycle. Count it as a failure until video + // actually flows (the livestreamActive handler resets this on recovery). + this.consecutiveFailedRecycles++; + + const triggerContext = + info.reason === "cold-start-counter-maxed" + ? `${info.attempts} no-data starts` + : `${info.staleMs}ms data stall (${info.consumers} consumer(s))`; + + this.stationRecycleInFlight = true; + this.lastStationRecycleAt = now; + // Tell the stream server to defer any startLivestream commands while + // the bropat session is recovering. The stream server will re-arm + // automatically when this clears in the `finally` block below, so + // consumers that arrive during the recycle still get a stream once + // P2P is actually re-established. + this.streamServer.setRecycleInFlight(true); + try { + this.logger.warn( + `🔄 Upstream wedged (reason: ${info.reason}, ${triggerContext}) — recycling station P2P session for ${stationSN}${ + isSelfStation ? " (4G camera, self-station)" : "" + }`, + ); + + const stationCmd = this.wsClient.commands.station(stationSN); + + // Diagnostic: what does bropat think the station's connectivity is + // before we touch it? + try { + const status = await stationCmd.isConnected(); + this.logger.info( + `🔎 Pre-recycle station.isConnected → ${JSON.stringify(status)}`, + ); + } catch (e) { + this.logger.warn(`Pre-recycle isConnected check failed: ${e}`); + } + + try { + this.logger.info(`📴 station.disconnect(${stationSN})`); + await stationCmd.disconnect(); + this.logger.info(`✅ station.disconnect ack`); + } catch (e) { + this.logger.warn(`station.disconnect threw: ${e}`); + } + + // Brief pause so the bropat client's teardown can settle before + // we ask it to reopen the P2P channel. 2s matches the empirical + // settling time before bropat will accept a fresh connect cleanly. + await new Promise((r) => setTimeout(r, 2000)); + + // Subscribe to CONNECTED / CONNECTION_ERROR for this station BEFORE + // issuing connect — `station.connect()` returns when bropat accepts + // the command, but the underlying P2P establishment is async (10–25s + // for cellular cameras / cold HomeBase sessions). We need to wait + // for the real "session ready" signal before declaring success. + const connectionOutcome = this.waitForStationConnectionOutcome( + stationSN, + 30000, + ); + + try { + this.logger.info(`📡 station.connect(${stationSN})`); + await stationCmd.connect(); + this.logger.info(`✅ station.connect ack (P2P establishing…)`); + } catch (e) { + this.logger.warn(`station.connect threw: ${e}`); + } + + const outcome = await connectionOutcome; + switch (outcome) { + case "connected": + this.logger.info( + `🟢 station.connected event received for ${stationSN} — P2P session is up`, + ); + break; + case "connection-error": + this.logger.error( + `🔴 station.connection_error received for ${stationSN} — recycle did not establish P2P`, + ); + break; + case "timeout": + this.logger.warn( + `⏱️ Timed out (30s) waiting for station.connected event — P2P may still be coming up`, + ); + break; + } + + try { + const status = await stationCmd.isConnected(); + this.logger.info( + `🔎 Post-recycle station.isConnected → ${JSON.stringify(status)}`, + ); + } catch (e) { + this.logger.warn(`Post-recycle isConnected check failed: ${e}`); + } + + this.logger.info( + `🔄 Station P2P recycle complete (outcome: ${outcome}) — re-arming any waiting consumers`, + ); + } finally { + this.stationRecycleInFlight = false; + // Clears the stream server's defer-flag. If consumers are still + // attached, this kicks off a fresh ensureLivestreamState so the + // user gets video without having to manually retry. + this.streamServer.setRecycleInFlight(false); + } + } + + /** + * Wait for the next `STATION_EVENTS.CONNECTED` or + * `STATION_EVENTS.CONNECTION_ERROR` event for a specific station serial, + * or time out. Returns the outcome as a string for logging. + * + * Listeners are registered eagerly (before `station.connect()` is sent) + * by the caller so we don't miss a fast-arriving CONNECTED event. + * Both listeners are removed on any resolution path. + */ + private waitForStationConnectionOutcome( + stationSerialNumber: string, + timeoutMs: number, + ): Promise<"connected" | "connection-error" | "timeout"> { + return new Promise((resolve) => { + let removeConnected: (() => boolean) | undefined; + let removeError: (() => boolean) | undefined; + const cleanup = () => { + removeConnected?.(); + removeError?.(); + }; + + const timer = setTimeout(() => { + cleanup(); + resolve("timeout"); + }, timeoutMs); + timer.unref?.(); + + removeConnected = this.wsClient.addEventListener( + STATION_EVENTS.CONNECTED, + () => { + clearTimeout(timer); + cleanup(); + resolve("connected"); + }, + { + source: EVENT_SOURCES.STATION, + serialNumber: stationSerialNumber, + }, + ); + + removeError = this.wsClient.addEventListener( + STATION_EVENTS.CONNECTION_ERROR, + () => { + clearTimeout(timer); + cleanup(); + resolve("connection-error"); + }, + { + source: EVENT_SOURCES.STATION, + serialNumber: stationSerialNumber, + }, + ); + }); + } + /** * Clean up resources on disposal */ @@ -846,6 +1392,10 @@ export class EufyDevice this.talkbackActive = false; this.intercomStartedLivestream = false; + // Stop the background thumbnail refresh timers. + if (this.thumbnailRefreshKick) clearTimeout(this.thumbnailRefreshKick); + if (this.thumbnailRefreshInterval) clearInterval(this.thumbnailRefreshInterval); + // Dispose stream service (will stop stream server if running) this.streamService .dispose() diff --git a/packages/eufy-security-scrypted/src/services/device/snapshot-service.ts b/packages/eufy-security-scrypted/src/services/device/snapshot-service.ts index 26ce838..846d820 100644 --- a/packages/eufy-security-scrypted/src/services/device/snapshot-service.ts +++ b/packages/eufy-security-scrypted/src/services/device/snapshot-service.ts @@ -19,6 +19,16 @@ import { IStreamServer } from "./types"; * This service captures H.264 keyframes from the camera stream * and converts them to JPEG images using FFmpeg. */ +// Tiny solid-color JPEG (320x180) returned for a thumbnail when no real frame +// has been cached yet. Returning a VALID image (rather than throwing) is +// essential: on a takePicture rejection, Scrypted's Snapshot plugin falls back +// to pulling a frame from the *video stream*, which starts a livestream and +// re-introduces the HomeBase stampede we're trying to avoid. +const PLACEHOLDER_JPEG = Buffer.from( + "/9j/4AAQSkZJRgABAgAAAQABAAD//gAQTGF2YzYyLjI4LjEwMAD/2wBDAAgEBAQEBAUFBQUFBQYGBgYGBgYGBgYGBgYHBwcICAgHBwcGBgcHCAgICAkJCQgICAgJCQoKCgwMCwsODg4RERT/xABMAAEBAAAAAAAAAAAAAAAAAAAABwEBAQAAAAAAAAAAAAAAAAAAAAEQAQAAAAAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAAAD/wAARCAC0AUADASIAAhEAAxEA/9oADAMBAAIRAxEAPwCNgKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//Z", + "base64", +); + export class SnapshotService { constructor( private serialNumber: string, @@ -32,8 +42,10 @@ export class SnapshotService { * @returns Picture options with default timeout */ getPictureOptions(): RequestPictureOptions { + // 60s default. Battery cameras (T8170 S340 deep sleep, T86P2 4G LTE + // cold-start) need 30–45s of P2P warm-up before the first IDR arrives. return { - timeout: 15000, // 15 seconds default + timeout: 60000, }; } @@ -49,36 +61,39 @@ export class SnapshotService { this.logger.info("📸 Taking snapshot from camera stream"); try { - // Use timeout from options or default to 15 seconds - const timeout = options?.timeout || 15000; - - this.logger.info(`Using timeout: ${timeout}ms for snapshot capture`); - - // The stream server handles starting/stopping the camera stream automatically - // It starts the camera stream, waits for a keyframe, captures it, then stops the stream - const h264Keyframe = await this.streamServer.captureSnapshot(timeout); - - this.logger.info( - `Captured H.264 keyframe: ${h264Keyframe.length} bytes - converting to JPEG`, - ); - - // Detect codec from last received stream metadata (H264 or H265) - const videoCodec = - this.streamServer.getVideoMetadata()?.videoCodec ?? "H264"; - - // Convert keyframe to JPEG using FFmpeg - const jpegBuffer = await FFmpegUtils.convertH264ToJPEG( - h264Keyframe, - 2, - videoCodec, + // Thumbnails are served from cache ONLY — never wake the camera. + // + // A HomeBase serves just one camera P2P stream at a time. The Home-app + // "Cameras" grid fires a snapshot request for every camera at once; if + // each cache-miss woke its camera, they'd all stampede the single + // HomeBase slot and fail together (the wedge cascade). The cache is + // refreshed for free whenever the camera is genuinely awake — live + // view, motion/HKSV recording, or the serial background refresh — so + // thumbnails are "last seen", which is the correct, battery-friendly + // behavior for these cameras. `options` (incl. any timeout) is ignored + // on purpose: there is no on-demand wake here. + void options; + const cached = this.streamServer.getCachedKeyframe( + Number.POSITIVE_INFINITY, ); + if (cached) { + this.logger.info( + `📸 Serving snapshot from cached keyframe: ${cached.data.length} bytes, ` + + `${Math.round(cached.ageMs / 1000)}s old, ${cached.codec} — no camera wake`, + ); + return await this.toJpegMediaObject(cached.data, cached.codec); + } + // No frame cached yet (camera hasn't streamed this session — e.g. right + // after a plugin reload). Return a placeholder rather than throwing: + // throwing makes Scrypted's Snapshot plugin fall back to the video + // stream, which starts a livestream and re-creates the stampede. The + // real frame appears once the camera next streams (tap / motion / the + // serial background refresh). this.logger.info( - `✅ Snapshot converted to JPEG: ${jpegBuffer.length} bytes`, + "📸 No cached frame yet — serving placeholder (not waking camera for a thumbnail)", ); - - // Create MediaObject with JPEG image - return sdk.mediaManager.createMediaObject(jpegBuffer, "image/jpeg", { + return sdk.mediaManager.createMediaObject(PLACEHOLDER_JPEG, "image/jpeg", { sourceId: this.serialNumber, }); } catch (error) { @@ -87,6 +102,31 @@ export class SnapshotService { } } + /** + * Convert a raw H.264/H.265 keyframe to a JPEG MediaObject. + * + * @param keyframe - Self-contained keyframe bitstream (parameter sets included) + * @param videoCodec - Codec of the keyframe ("H264" or "H265") + */ + private async toJpegMediaObject( + keyframe: Buffer, + videoCodec: string, + ): Promise { + const jpegBuffer = await FFmpegUtils.convertH264ToJPEG( + keyframe, + 2, + videoCodec, + ); + + this.logger.info( + `✅ Snapshot converted to JPEG: ${jpegBuffer.length} bytes`, + ); + + return sdk.mediaManager.createMediaObject(jpegBuffer, "image/jpeg", { + sourceId: this.serialNumber, + }); + } + /** * Clean up resources */ diff --git a/packages/eufy-security-scrypted/src/services/device/stream-service.ts b/packages/eufy-security-scrypted/src/services/device/stream-service.ts index 54f8101..ab0ab4e 100644 --- a/packages/eufy-security-scrypted/src/services/device/stream-service.ts +++ b/packages/eufy-security-scrypted/src/services/device/stream-service.ts @@ -236,10 +236,15 @@ export class StreamService { name: options?.name || "Eufy Camera Stream", container: useMuxed ? "mp4" : options?.container, video: { + ...options?.video, + // Our source codec is authoritative. A consumer (e.g. HomeKit) + // requests `codec: 'h264'`; spreading that over ours would relabel + // our H.265 stream as H.264, so it gets `-vcodec copy`'d as-is and + // fails ("codec must be h264 but is h265"). Report what we actually + // send (h265 for these cameras) so downstream transcodes correctly. codec: scryptedCodec, width, height, - ...options?.video, }, ...(useMuxed && { audio: { codec: "aac" } }), }, diff --git a/packages/eufy-security-scrypted/src/services/device/types.ts b/packages/eufy-security-scrypted/src/services/device/types.ts index 4d215d4..7206b87 100644 --- a/packages/eufy-security-scrypted/src/services/device/types.ts +++ b/packages/eufy-security-scrypted/src/services/device/types.ts @@ -44,6 +44,17 @@ export interface IStreamServer { */ captureSnapshot(timeout?: number): Promise; + /** + * Return the most recently seen keyframe if no older than `maxAgeMs`, + * otherwise null. Lets the snapshot service serve a thumbnail without + * waking the camera. The buffer is self-contained (parameter sets + * prepended) and decodes to a JPEG on its own. + * @param maxAgeMs - Maximum acceptable age of the cached keyframe, in ms + */ + getCachedKeyframe( + maxAgeMs: number, + ): { data: Buffer; codec: "H264" | "H265"; ageMs: number } | null; + /** * Get the last received video metadata (codec, resolution, FPS). * Returns null if no stream has been received yet. diff --git a/packages/eufy-security-scrypted/src/utils/recycle-guard.ts b/packages/eufy-security-scrypted/src/utils/recycle-guard.ts new file mode 100644 index 0000000..068ef94 --- /dev/null +++ b/packages/eufy-security-scrypted/src/utils/recycle-guard.ts @@ -0,0 +1,60 @@ +/** + * Station-recycle guard + * + * A wedged camera recovers by recycling its HomeBase's P2P session, which + * briefly disrupts EVERY camera on that HomeBase. That's worth it when the + * recycle actually recovers the camera — but a camera that can't stream at all + * (no WiFi signal, dead/unreachable) just keeps wedging, and recycling for it + * repeatedly punishes its healthy siblings for nothing. + * + * This decides when to STOP recycling for such a camera. Pure logic so it can + * be unit-tested without the device machinery. + * + * @module utils/recycle-guard + */ + +/** + * How many recycles that fail to recover the camera we tolerate before + * suppressing. 1 = give it a single recycle (which genuinely fixes a wedged + * session, e.g. Front Door), then stop if it still won't stream. + */ +export const MAX_FAILED_RECYCLES = 1; + +/** How long to suppress recycles for a chronically-failing camera. */ +export const RECYCLE_SUPPRESS_MS = 30 * 60 * 1000; // 30 minutes + +export interface RecycleSuppressionInput { + /** Is this camera its own station (4G LTE)? Self-stations have no siblings. */ + isSelfStation: boolean; + /** Camera's reported WiFi signal level (0 = no usable signal), if known. */ + signalLevel: number | undefined; + /** Recycles so far that did NOT recover the camera (reset when video flows). */ + consecutiveFailedRecycles: number; +} + +export interface RecycleSuppressionResult { + suppress: boolean; + reason?: "no-signal" | "chronic-failure"; +} + +/** + * Should we suppress the station recycle (to protect sibling cameras)? + * + * - HomeBase camera reporting signal level 0 → it physically can't stream; a + * recycle won't help and only disrupts siblings. Suppress. + * - Already failed `MAX_FAILED_RECYCLES` recycles without recovering → stop. + * + * Self-station (4G) cameras have no siblings to protect, so the no-signal + * short-circuit doesn't apply to them (they may still hit the failure cap). + */ +export function recycleSuppression( + input: RecycleSuppressionInput, +): RecycleSuppressionResult { + if (!input.isSelfStation && input.signalLevel === 0) { + return { suppress: true, reason: "no-signal" }; + } + if (input.consecutiveFailedRecycles + 1 > MAX_FAILED_RECYCLES) { + return { suppress: true, reason: "chronic-failure" }; + } + return { suppress: false }; +} diff --git a/packages/eufy-security-scrypted/src/utils/station-stream-coordinator.ts b/packages/eufy-security-scrypted/src/utils/station-stream-coordinator.ts new file mode 100644 index 0000000..04a2708 --- /dev/null +++ b/packages/eufy-security-scrypted/src/utils/station-stream-coordinator.ts @@ -0,0 +1,225 @@ +/** + * Station stream coordinator + * + * A Eufy HomeBase serves only ONE camera P2P stream at a time. Starting a + * second stream starves the first, and two simultaneous starts break both. + * This coordinator enforces a single "slot" per station so cameras never + * stampede the shared HomeBase. + * + * Process-wide and keyed by station serial (4G LTE cameras are their own + * station, so they never contend with each other). All EufyDevices in a + * Scrypted plugin share one process, so a module-level map is sufficient. + * + * Priorities: + * - "live" — a real viewer/recorder (HomeKit live view, HKSV). + * - "background" — the serial thumbnail refresh. + * + * Rules: + * - Slot free → granted. + * - "live" wants a held slot → PREEMPT the holder (its onRevoke fires, it + * must stop), then grant. This covers both + * live-over-background and live-over-live + * ("newest tap wins"). + * - "background" wants a held slot → DENIED (returns null). Background work + * never interrupts a viewer/recorder; the caller + * skips that camera and tries again later. + * + * The grant is SYNCHRONOUS (in-memory) so callers can gate `startLivestream` + * without introducing async races. `onRevoke` triggers the previous holder's + * stop asynchronously; its eventual `release()` is a no-op once the slot has + * moved on. + * + * @module utils/station-stream-coordinator + */ + +export type StreamPriority = "live" | "background"; + +export interface StationSlotLease { + /** Release the slot (idempotent). No-op if already superseded/released. */ + release(): void; + /** True while this lease still owns the station slot. */ + readonly active: boolean; + /** + * Resolves when it is safe to actually start streaming: if this grant + * preempted another camera, it resolves once that camera has released + * (or a safety timeout), so the two don't overlap on the HomeBase. When + * nothing was preempted it is already resolved. + */ + readonly whenReady: Promise; + /** + * Mark that this camera is now actually delivering video. A delivering + * holder will NOT be preempted by another camera's live request — only a + * stuck/warming holder can be taken over. This is what stops the Home-app + * grid (which fires a live preview request per camera) from kicking a + * working stream off the single HomeBase slot. + */ + markDelivering(): void; +} + +interface Holder { + deviceSN: string; + priority: StreamPriority; + onRevoke: () => void; + /** Callbacks fired when THIS holder's lease is released. */ + onReleased: Array<() => void>; + /** When this holder acquired the slot (for the warm-up grace). */ + acquiredAt: number; + /** Whether this holder is actually delivering video yet. */ + isDelivering: boolean; +} + +// Max time a preemptor waits for the previous holder to release before +// starting anyway (the holder should stop within ~1-2s; this just prevents a +// hang if it never does). +const PREEMPT_HANDOFF_TIMEOUT_MS = 4000; + +// A freshly-granted holder is protected from preemption this long, giving a +// battery camera time to wake and deliver its first frame before another +// request can take the slot. Prevents the grid's burst of live requests from +// thrashing during warm-up. +const PREEMPT_MIN_HOLD_MS = 8000; + +const holders = new Map(); + +/** + * Attempt to acquire the single stream slot for `stationSN` on behalf of + * `deviceSN`. + * + * Returns a lease, or null if DENIED: + * - "background" is denied whenever the slot is held. + * - "live" is denied only while the current live holder is within its brief + * warm-up window (`PREEMPT_MIN_HOLD_MS`). Past that window a live request + * takes over the slot — newest tap wins — via a clean handoff, even from a + * holder that is actively delivering. The warm-up window absorbs the + * Home-app grid's burst of near-simultaneous live requests (they all land + * within a second or two, so they hit the guard and are denied, letting one + * camera win without thrash); a request that arrives AFTER the window is + * necessarily a deliberate switch to another camera and is honored. + * + * @param nowMs - current time (injectable for tests) + */ +export function acquireStationSlot( + stationSN: string, + deviceSN: string, + priority: StreamPriority, + onRevoke: () => void, + nowMs: number = Date.now(), +): StationSlotLease | null { + const current = holders.get(stationSN); + + let whenReady: Promise = Promise.resolve(); + + if (current && current.deviceSN !== deviceSN) { + if (priority === "background") { + // Background never interrupts a holder. + return null; + } + // "live" always beats a background holder (a thumbnail refresh). Against + // another LIVE holder, protect it ONLY during its warm-up window: that + // absorbs the Home-app grid's stampede of near-simultaneous live requests + // (all within a second or two → denied → one camera wins, no thrash). + // After the window, a live request is necessarily a deliberate switch to + // another camera, so it preempts and takes over — newest tap wins — even + // from a delivering holder. Switches are therefore spaced at least one + // warm-up apart, which keeps the takeover from churning the HomeBase. + if (current.priority === "live") { + const withinWarmup = nowMs - current.acquiredAt < PREEMPT_MIN_HOLD_MS; + if (withinWarmup) { + return null; + } + } + // Wait for the preempted holder to release before we start, so the two + // don't overlap on the single HomeBase slot. + whenReady = new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; + current.onReleased.push(finish); + setTimeout(finish, PREEMPT_HANDOFF_TIMEOUT_MS); + }); + try { + current.onRevoke(); + } catch { + // A misbehaving revoke handler must not block the new grant. + } + } + + const holder: Holder = { + deviceSN, + priority, + onRevoke, + onReleased: [], + acquiredAt: nowMs, + isDelivering: false, + }; + holders.set(stationSN, holder); + + let released = false; + return { + whenReady, + markDelivering() { + holder.isDelivering = true; + }, + get active() { + return !released && holders.get(stationSN) === holder; + }, + release() { + if (released) return; + released = true; + // Notify any preemptor waiting for us to step off the slot. + const callbacks = holder.onReleased; + holder.onReleased = []; + for (const cb of callbacks) { + try { + cb(); + } catch { + // ignore + } + } + // Only clear the map if WE still own it (a later acquire may have + // already replaced us — don't clobber the new holder). + if (holders.get(stationSN) === holder) holders.delete(stationSN); + }, + }; +} + +/** + * Is the station slot currently held by a device OTHER than `deviceSN`? + * Used to decide whether a "no video" condition is a real upstream wedge + * (we hold the slot and still get nothing) versus expected contention + * (someone else holds it), so we don't recycle the HomeBase needlessly. + */ +export function isStationSlotHeldByOther( + stationSN: string, + deviceSN: string, +): boolean { + const h = holders.get(stationSN); + return !!h && h.deviceSN !== deviceSN; +} + +/** The device currently holding the slot, or undefined. */ +export function stationSlotHolder(stationSN: string): string | undefined { + return holders.get(stationSN)?.deviceSN; +} + +/** + * If another device on `stationSN` currently holds the slot AND is actually + * delivering video, return its serial; otherwise undefined. Used to decide + * whether to defer a station P2P recycle (don't tear down a sibling's working + * stream). Replaces the old separate "stream registry". + */ +export function otherDeviceDeliveringOnStation( + stationSN: string, + deviceSN: string, +): string | undefined { + const h = holders.get(stationSN); + return h && h.deviceSN !== deviceSN && h.isDelivering ? h.deviceSN : undefined; +} + +/** Test-only: clear all slot state. */ +export function _resetStationStreamCoordinator(): void { + holders.clear(); +} diff --git a/packages/eufy-security-scrypted/src/utils/thumbnail-refresh.ts b/packages/eufy-security-scrypted/src/utils/thumbnail-refresh.ts new file mode 100644 index 0000000..868bf8f --- /dev/null +++ b/packages/eufy-security-scrypted/src/utils/thumbnail-refresh.ts @@ -0,0 +1,94 @@ +/** + * Thumbnail refresh policy + * + * Decides whether a battery camera's cached thumbnail should be refreshed by a + * gentle background wake. Pure logic so it can be unit-tested without the + * device/stream machinery. + * + * @module utils/thumbnail-refresh + */ + +/** Default age before a background refresh is allowed (overridable per camera). */ +export const THUMBNAIL_REFRESH_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours + +/** + * User-facing choices for the per-camera "Background Thumbnail Refresh" + * setting → threshold in ms, or null to disable. Solar cams can afford a + * shorter interval; battery/LTE cams should run longer or off. + */ +export const THUMBNAIL_REFRESH_CHOICES: Record = { + Off: null, + "30 minutes": 30 * 60 * 1000, + "1 hour": 60 * 60 * 1000, + "2 hours": 2 * 60 * 60 * 1000, + "4 hours": 4 * 60 * 60 * 1000, +}; + +export const THUMBNAIL_REFRESH_DEFAULT_CHOICE = "2 hours"; + +/** + * Resolve a stored setting choice to a threshold in ms, or null if disabled. + * Unknown/unset choices fall back to the default (2 hours). + */ +export function resolveRefreshChoice(choice: string | undefined): number | null { + if (choice && choice in THUMBNAIL_REFRESH_CHOICES) { + return THUMBNAIL_REFRESH_CHOICES[choice]; + } + return THUMBNAIL_REFRESH_CHOICES[THUMBNAIL_REFRESH_DEFAULT_CHOICE]; +} + +export interface RefreshDecisionInput { + /** Age of the cached keyframe in ms, or null if nothing is cached. */ + cacheAgeMs: number | null; + /** Is another camera on this HomeBase currently holding the stream slot? */ + slotBusy: boolean; + /** + * ms until this camera's refresh backoff expires (0 if not backing off). + * Set after a failed wake so a dead/asleep camera (e.g. one that delivers + * no video) isn't hammered every cycle. + */ + backoffRemainingMs: number; + /** Refresh threshold (defaults to THUMBNAIL_REFRESH_THRESHOLD_MS). */ + thresholdMs?: number; +} + +/** + * Should we wake this camera now to refresh its thumbnail? + * + * Refresh only when ALL hold: + * - the cache is already populated AND older than the threshold, + * - the HomeBase slot is free (never interrupt a viewer/recorder), and + * - we're not in failure backoff for this camera. + * + * An EMPTY cache does NOT trigger a refresh. The in-memory cache is empty for + * every camera right after a plugin reload, so waking on empty would stampede + * the whole fleet (including disabled/unused and dead cameras) on every + * restart. The first thumbnail is populated on demand instead (a live view or + * a motion recording); the background refresh only keeps an already-seen tile + * from going stale. + */ +export function shouldRefreshThumbnail({ + cacheAgeMs, + slotBusy, + backoffRemainingMs, + thresholdMs = THUMBNAIL_REFRESH_THRESHOLD_MS, +}: RefreshDecisionInput): boolean { + if (slotBusy) return false; + if (backoffRemainingMs > 0) return false; + if (cacheAgeMs === null) return false; // never streamed → don't proactively wake + return cacheAgeMs >= thresholdMs; +} + +/** + * Next backoff duration (ms) after a failed refresh. Exponential from a base, + * capped — so a camera that never streams (dead/asleep) is retried rarely + * instead of every cycle. + */ +export function nextRefreshBackoffMs( + consecutiveFailures: number, + baseMs = 10 * 60 * 1000, // 10 min + capMs = 6 * 60 * 60 * 1000, // 6 h +): number { + const n = Math.max(1, consecutiveFailures); + return Math.min(capMs, baseMs * Math.pow(2, n - 1)); +} diff --git a/packages/eufy-security-scrypted/tests/unit/eufy-device-intercom.test.ts b/packages/eufy-security-scrypted/tests/unit/eufy-device-intercom.test.ts index c8a229b..c23600c 100644 --- a/packages/eufy-security-scrypted/tests/unit/eufy-device-intercom.test.ts +++ b/packages/eufy-security-scrypted/tests/unit/eufy-device-intercom.test.ts @@ -27,6 +27,19 @@ jest.mock("@scrypted/sdk", () => { __esModule: true, ScryptedDeviceBase: class { info: any = { serialNumber: "TEST123" }; + storage: any = { + _m: new Map(), + getItem(k: string) { + return this._m.has(k) ? this._m.get(k) : null; + }, + setItem(k: string, v: string) { + this._m.set(k, String(v)); + }, + removeItem(k: string) { + this._m.delete(k); + }, + }; + log: any = { a: jest.fn(), clearAlert: jest.fn() }; }, ScryptedInterface: { MotionSensor: "MotionSensor", @@ -55,7 +68,16 @@ jest.mock("@caplaz/eufy-stream-server", () => ({ start: jest.fn().mockResolvedValue(undefined), stop: jest.fn().mockResolvedValue(undefined), getPort: jest.fn().mockReturnValue(8080), + getMuxedPort: jest.fn().mockReturnValue(undefined), getActiveConnectionCount: jest.fn().mockReturnValue(0), + getVideoMetadata: jest.fn().mockReturnValue(null), + getCachedKeyframe: jest.fn().mockReturnValue(null), + setCachedKeyframe: jest.fn(), + captureSnapshot: jest.fn(), + isRunning: jest.fn().mockReturnValue(false), + on: jest.fn(), + off: jest.fn(), + removeListener: jest.fn(), })), })); diff --git a/packages/eufy-security-scrypted/tests/unit/eufy-device-ptz.test.ts b/packages/eufy-security-scrypted/tests/unit/eufy-device-ptz.test.ts index 6e561b9..8dcc4e6 100644 --- a/packages/eufy-security-scrypted/tests/unit/eufy-device-ptz.test.ts +++ b/packages/eufy-security-scrypted/tests/unit/eufy-device-ptz.test.ts @@ -15,6 +15,20 @@ import { Logger, ILogObj } from "tslog"; jest.mock("@scrypted/sdk", () => ({ ScryptedDeviceBase: class { ptzCapabilities = { pan: false, tilt: false, zoom: false }; + info: any = { serialNumber: "TEST123" }; + storage: any = { + _m: new Map(), + getItem(k: string) { + return this._m.has(k) ? this._m.get(k) : null; + }, + setItem(k: string, v: string) { + this._m.set(k, String(v)); + }, + removeItem(k: string) { + this._m.delete(k); + }, + }; + log: any = { a: jest.fn(), clearAlert: jest.fn() }; }, ScryptedInterface: { MotionSensor: "MotionSensor", @@ -51,6 +65,16 @@ jest.mock("@caplaz/eufy-stream-server", () => ({ start: jest.fn().mockResolvedValue(undefined), stop: jest.fn().mockResolvedValue(undefined), getPort: jest.fn().mockReturnValue(8080), + getMuxedPort: jest.fn().mockReturnValue(undefined), + getActiveConnectionCount: jest.fn().mockReturnValue(0), + getVideoMetadata: jest.fn().mockReturnValue(null), + getCachedKeyframe: jest.fn().mockReturnValue(null), + setCachedKeyframe: jest.fn(), + captureSnapshot: jest.fn(), + isRunning: jest.fn().mockReturnValue(false), + on: jest.fn(), + off: jest.fn(), + removeListener: jest.fn(), })), })); diff --git a/packages/eufy-security-scrypted/tests/unit/services/snapshot-service.test.ts b/packages/eufy-security-scrypted/tests/unit/services/snapshot-service.test.ts index 6fa48e1..f9bb48d 100644 --- a/packages/eufy-security-scrypted/tests/unit/services/snapshot-service.test.ts +++ b/packages/eufy-security-scrypted/tests/unit/services/snapshot-service.test.ts @@ -45,6 +45,9 @@ describe("SnapshotService", () => { mockStreamServer = { captureSnapshot: jest.fn().mockResolvedValue(mockH264Data), getVideoMetadata: jest.fn().mockReturnValue(null), + // Default: no fresh cached frame, so takePicture falls back to a live + // capture. Cache-hit behavior is exercised in its own describe block. + getCachedKeyframe: jest.fn().mockReturnValue(null), } as any; // Mock FFmpegUtils static method @@ -68,59 +71,85 @@ describe("SnapshotService", () => { const options = service.getPictureOptions(); expect(options).toEqual({ - timeout: 15000, + timeout: 60000, }); }); }); - describe("takePicture", () => { - it("should capture snapshot and convert to JPEG", async () => { + describe("takePicture — cache-only (never wakes the camera)", () => { + const cachedKeyframe = Buffer.from([ + 0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x99, + ]); // pretend H.265 keyframe + + it("serves the cached frame and converts it to JPEG", async () => { + mockStreamServer.getCachedKeyframe = jest.fn().mockReturnValue({ + data: cachedKeyframe, + codec: "H265", + ageMs: 4200, + }); + const result = await service.takePicture(); - expect(mockStreamServer.captureSnapshot).toHaveBeenCalledWith(15000); + // Cached frame converted with its own stored codec. expect(FFmpegUtils.convertH264ToJPEG).toHaveBeenCalledWith( - mockH264Data, + cachedKeyframe, 2, - "H264", // default when getVideoMetadata returns null + "H265", ); expect(sdk.mediaManager.createMediaObject).toHaveBeenCalledWith( mockJpegData, "image/jpeg", { sourceId: serialNumber }, ); - expect(mockLogger.info).toHaveBeenCalledWith( - "📸 Taking snapshot from camera stream", - ); + expect(result).toBeDefined(); + // The whole point: a thumbnail NEVER wakes the camera. + expect(mockStreamServer.captureSnapshot).not.toHaveBeenCalled(); }); - it("should use custom timeout from options", async () => { - await service.takePicture({ timeout: 20000 }); - - expect(mockStreamServer.captureSnapshot).toHaveBeenCalledWith(20000); - }); - - it("should log snapshot size", async () => { + it("requests the cache at any age (never treats a frame as too stale)", async () => { + mockStreamServer.getCachedKeyframe = jest.fn().mockReturnValue({ + data: cachedKeyframe, + codec: "H265", + ageMs: 999999, + }); await service.takePicture(); - - expect(mockLogger.info).toHaveBeenCalledWith( - `Captured H.264 keyframe: ${mockH264Data.length} bytes - converting to JPEG`, - ); - expect(mockLogger.info).toHaveBeenCalledWith( - `✅ Snapshot converted to JPEG: ${mockJpegData.length} bytes`, + expect(mockStreamServer.getCachedKeyframe).toHaveBeenCalledWith( + Number.POSITIVE_INFINITY, ); }); - it("should handle capture errors", async () => { - const error = new Error("Capture failed"); - mockStreamServer.captureSnapshot.mockRejectedValueOnce(error); + it("ignores any requested timeout (no on-demand wake path)", async () => { + mockStreamServer.getCachedKeyframe = jest.fn().mockReturnValue({ + data: cachedKeyframe, + codec: "H265", + ageMs: 10, + }); + await service.takePicture({ timeout: 20000 }); + expect(mockStreamServer.captureSnapshot).not.toHaveBeenCalled(); + }); - await expect(service.takePicture()).rejects.toThrow("Capture failed"); - expect(mockLogger.error).toHaveBeenCalledWith( - `Failed to capture snapshot: ${error}`, + it("returns a placeholder (never throws, never wakes) when no frame is cached yet", async () => { + mockStreamServer.getCachedKeyframe = jest.fn().mockReturnValue(null); + + // Must resolve, not reject: a rejection makes Scrypted's Snapshot plugin + // fall back to the video stream, which would wake the camera. + const result = await service.takePicture(); + expect(result).toBeDefined(); + expect(mockStreamServer.captureSnapshot).not.toHaveBeenCalled(); + // A real (placeholder) image MediaObject is created. + expect(sdk.mediaManager.createMediaObject).toHaveBeenCalledWith( + expect.any(Buffer), + "image/jpeg", + { sourceId: serialNumber }, ); }); - it("should handle conversion errors", async () => { + it("propagates conversion errors", async () => { + mockStreamServer.getCachedKeyframe = jest.fn().mockReturnValue({ + data: cachedKeyframe, + codec: "H265", + ageMs: 10, + }); const error = new Error("Conversion failed"); (FFmpegUtils.convertH264ToJPEG as jest.Mock).mockRejectedValueOnce(error); @@ -129,14 +158,6 @@ describe("SnapshotService", () => { `Failed to capture snapshot: ${error}`, ); }); - - it("should work with different timeout values", async () => { - await service.takePicture({ timeout: 5000 }); - expect(mockStreamServer.captureSnapshot).toHaveBeenCalledWith(5000); - - await service.takePicture({ timeout: 30000 }); - expect(mockStreamServer.captureSnapshot).toHaveBeenCalledWith(30000); - }); }); describe("dispose", () => { diff --git a/packages/eufy-security-scrypted/tests/unit/services/stream-service.test.ts b/packages/eufy-security-scrypted/tests/unit/services/stream-service.test.ts index 48fc9fc..c0bbfd8 100644 --- a/packages/eufy-security-scrypted/tests/unit/services/stream-service.test.ts +++ b/packages/eufy-security-scrypted/tests/unit/services/stream-service.test.ts @@ -195,6 +195,30 @@ describe("StreamService", () => { ); }); + it("reports our actual source codec, not a consumer's requested codec", async () => { + // Source is H.265. A consumer (HomeKit) requests h264; we must NOT relabel + // our stream as h264 — that gets it `-vcodec copy`'d as-is and fails. + mockStreamServer.getVideoMetadata = jest.fn().mockReturnValue({ + videoCodec: "H265", + videoWidth: 1920, + videoHeight: 1080, + videoFPS: 15, + }); + + await service.getVideoStream(VideoQuality.HIGH, { + id: "main", + video: { codec: "h264" }, + } as any); + + expect(sdk.mediaManager.createFFmpegMediaObject).toHaveBeenCalledWith( + expect.objectContaining({ + mediaStreamOptions: expect.objectContaining({ + video: expect.objectContaining({ codec: "h265" }), + }), + }), + ); + }); + it("should use default stream options if not provided", async () => { await service.getVideoStream(VideoQuality.MEDIUM); diff --git a/packages/eufy-security-scrypted/tests/unit/utils/recycle-guard.test.ts b/packages/eufy-security-scrypted/tests/unit/utils/recycle-guard.test.ts new file mode 100644 index 0000000..c0ca13e --- /dev/null +++ b/packages/eufy-security-scrypted/tests/unit/utils/recycle-guard.test.ts @@ -0,0 +1,52 @@ +/** + * Station-recycle guard tests + */ + +import { + recycleSuppression, + MAX_FAILED_RECYCLES, +} from "../../../src/utils/recycle-guard"; + +describe("recycleSuppression", () => { + const base = { + isSelfStation: false, + signalLevel: 3, + consecutiveFailedRecycles: 0, + }; + + it("allows the first recycle (gives a wedged session one chance)", () => { + expect(recycleSuppression(base)).toEqual({ suppress: false }); + }); + + it("suppresses after the failure cap (chronic failure)", () => { + expect( + recycleSuppression({ ...base, consecutiveFailedRecycles: MAX_FAILED_RECYCLES }), + ).toEqual({ suppress: true, reason: "chronic-failure" }); + }); + + it("suppresses immediately for a HomeBase camera with no signal (level 0)", () => { + expect(recycleSuppression({ ...base, signalLevel: 0 })).toEqual({ + suppress: true, + reason: "no-signal", + }); + }); + + it("does not no-signal-short-circuit a self-station (4G) camera", () => { + // 4G self-station has no siblings to protect; only the failure cap applies. + expect( + recycleSuppression({ ...base, isSelfStation: true, signalLevel: 0 }), + ).toEqual({ suppress: false }); + }); + + it("does not suppress on decent signal before the cap", () => { + expect( + recycleSuppression({ ...base, signalLevel: 1, consecutiveFailedRecycles: 0 }), + ).toEqual({ suppress: false }); + }); + + it("treats unknown signal as not-no-signal", () => { + expect( + recycleSuppression({ ...base, signalLevel: undefined }), + ).toEqual({ suppress: false }); + }); +}); diff --git a/packages/eufy-security-scrypted/tests/unit/utils/station-stream-coordinator.test.ts b/packages/eufy-security-scrypted/tests/unit/utils/station-stream-coordinator.test.ts new file mode 100644 index 0000000..d698321 --- /dev/null +++ b/packages/eufy-security-scrypted/tests/unit/utils/station-stream-coordinator.test.ts @@ -0,0 +1,172 @@ +/** + * Station stream coordinator tests + */ + +import { + acquireStationSlot, + isStationSlotHeldByOther, + otherDeviceDeliveringOnStation, + stationSlotHolder, + _resetStationStreamCoordinator, +} from "../../../src/utils/station-stream-coordinator"; + +const ST = "T8030HOMEBASE"; +const A = "CAM_A"; +const B = "CAM_B"; +const C = "CAM_C"; + +describe("station-stream-coordinator", () => { + beforeEach(() => _resetStationStreamCoordinator()); + + it("grants the slot when free", () => { + const lease = acquireStationSlot(ST, A, "live", () => {}); + expect(lease).not.toBeNull(); + expect(lease!.active).toBe(true); + expect(stationSlotHolder(ST)).toBe(A); + }); + + it("denies a background request when the slot is held", () => { + acquireStationSlot(ST, A, "live", () => {}); + const bg = acquireStationSlot(ST, B, "background", () => {}); + expect(bg).toBeNull(); + expect(stationSlotHolder(ST)).toBe(A); + }); + + it("grants a background request when the slot is free", () => { + const bg = acquireStationSlot(ST, A, "background", () => {}); + expect(bg).not.toBeNull(); + expect(stationSlotHolder(ST)).toBe(A); + }); + + it("live PREEMPTS a background holder (revoke fires) and takes the slot", () => { + const revokeA = jest.fn(); + const leaseA = acquireStationSlot(ST, A, "background", revokeA); + const leaseB = acquireStationSlot(ST, B, "live", () => {}); + expect(revokeA).toHaveBeenCalledTimes(1); + expect(leaseB).not.toBeNull(); + expect(stationSlotHolder(ST)).toBe(B); + // A's lease is no longer active and its release is a harmless no-op. + expect(leaseA!.active).toBe(false); + leaseA!.release(); + expect(stationSlotHolder(ST)).toBe(B); // not clobbered + }); + + it("live preempts a STUCK older live holder (past warm-up, not delivering)", () => { + const revokeA = jest.fn(); + acquireStationSlot(ST, A, "live", revokeA, 0); + // B requests past A's warm-up grace; A never started delivering → take over. + const leaseB = acquireStationSlot(ST, B, "live", () => {}, 9000); + expect(revokeA).toHaveBeenCalledTimes(1); + expect(leaseB).not.toBeNull(); + expect(stationSlotHolder(ST)).toBe(B); + }); + + it("live does NOT preempt a live holder still in its warm-up grace", () => { + const revokeA = jest.fn(); + acquireStationSlot(ST, A, "live", revokeA, 0); + const leaseB = acquireStationSlot(ST, B, "live", () => {}, 1000); // within 8s + expect(revokeA).not.toHaveBeenCalled(); + expect(leaseB).toBeNull(); + expect(stationSlotHolder(ST)).toBe(A); + }); + + it("live preempts a DELIVERING live holder once past the warm-up window (deliberate switch wins)", () => { + const revokeA = jest.fn(); + const leaseA = acquireStationSlot(ST, A, "live", revokeA, 0); + leaseA!.markDelivering(); + // A request that arrives after the warm-up window is a deliberate switch to + // another camera — it takes over even though A is delivering. + const leaseB = acquireStationSlot(ST, B, "live", () => {}, 100000); + expect(revokeA).toHaveBeenCalledTimes(1); + expect(leaseB).not.toBeNull(); + expect(stationSlotHolder(ST)).toBe(B); + }); + + it("a DELIVERING live holder is still protected DURING its warm-up window", () => { + const revokeA = jest.fn(); + const leaseA = acquireStationSlot(ST, A, "live", revokeA, 0); + leaseA!.markDelivering(); + // Within warm-up (the grid-burst window) even a delivering holder is not + // kicked off, so the stampede of near-simultaneous requests can't thrash. + const leaseB = acquireStationSlot(ST, B, "live", () => {}, 1000); + expect(revokeA).not.toHaveBeenCalled(); + expect(leaseB).toBeNull(); + expect(stationSlotHolder(ST)).toBe(A); + }); + + it("does not preempt or revoke when the same device re-acquires", () => { + const revokeA = jest.fn(); + acquireStationSlot(ST, A, "live", revokeA); + const again = acquireStationSlot(ST, A, "live", revokeA); + expect(revokeA).not.toHaveBeenCalled(); + expect(again).not.toBeNull(); + expect(stationSlotHolder(ST)).toBe(A); + }); + + it("frees the slot on release so the next camera can take it", () => { + const lease = acquireStationSlot(ST, A, "live", () => {}); + lease!.release(); + expect(stationSlotHolder(ST)).toBeUndefined(); + const bg = acquireStationSlot(ST, B, "background", () => {}); + expect(bg).not.toBeNull(); + expect(stationSlotHolder(ST)).toBe(B); + }); + + it("keeps stations independent (4G self-stations never contend)", () => { + acquireStationSlot("STATION_1", A, "live", () => {}); + const other = acquireStationSlot("STATION_2", B, "background", () => {}); + expect(other).not.toBeNull(); // different station → free + }); + + it("isStationSlotHeldByOther reflects ownership", () => { + acquireStationSlot(ST, A, "live", () => {}); + expect(isStationSlotHeldByOther(ST, B)).toBe(true); + expect(isStationSlotHeldByOther(ST, A)).toBe(false); + }); + + it("otherDeviceDeliveringOnStation only reports a DELIVERING sibling", () => { + const leaseA = acquireStationSlot(ST, A, "live", () => {}); + // Holds the slot but not delivering yet → not reported. + expect(otherDeviceDeliveringOnStation(ST, B)).toBeUndefined(); + leaseA!.markDelivering(); + expect(otherDeviceDeliveringOnStation(ST, B)).toBe(A); + // Never reports the querying device itself. + expect(otherDeviceDeliveringOnStation(ST, A)).toBeUndefined(); + leaseA!.release(); + expect(otherDeviceDeliveringOnStation(ST, B)).toBeUndefined(); + }); + + it("whenReady resolves immediately when the slot was free", async () => { + const lease = acquireStationSlot(ST, A, "live", () => {}); + await expect( + Promise.race([ + lease!.whenReady.then(() => "ready"), + new Promise((r) => setTimeout(() => r("timeout"), 100)), + ]), + ).resolves.toBe("ready"); + }); + + it("whenReady waits for the preempted holder to release before resolving", async () => { + const leaseA = acquireStationSlot(ST, A, "live", () => {}, 0); + const leaseB = acquireStationSlot(ST, B, "live", () => {}, 9000); // preempts stuck A + + // Before A releases, B is not yet ready. + const early = await Promise.race([ + leaseB!.whenReady.then(() => "ready"), + new Promise((r) => setTimeout(() => r("pending"), 60)), + ]); + expect(early).toBe("pending"); + + // Once A releases, B becomes ready. + leaseA!.release(); + await expect(leaseB!.whenReady.then(() => "ready")).resolves.toBe("ready"); + }); + + it("a revoked holder releasing does not delete a newer holder's slot", () => { + const leaseA = acquireStationSlot(ST, A, "live", () => {}, 0); + acquireStationSlot(ST, B, "live", () => {}, 9000); // preempts stuck A + leaseA!.release(); // A finally tears down + expect(stationSlotHolder(ST)).toBe(B); + expect(isStationSlotHeldByOther(ST, B)).toBe(false); + }); +}); diff --git a/packages/eufy-security-scrypted/tests/unit/utils/thumbnail-refresh.test.ts b/packages/eufy-security-scrypted/tests/unit/utils/thumbnail-refresh.test.ts new file mode 100644 index 0000000..4ee9161 --- /dev/null +++ b/packages/eufy-security-scrypted/tests/unit/utils/thumbnail-refresh.test.ts @@ -0,0 +1,85 @@ +/** + * Thumbnail refresh policy tests + */ + +import { + shouldRefreshThumbnail, + nextRefreshBackoffMs, + THUMBNAIL_REFRESH_THRESHOLD_MS, + resolveRefreshChoice, +} from "../../../src/utils/thumbnail-refresh"; + +describe("shouldRefreshThumbnail", () => { + const base = { cacheAgeMs: null, slotBusy: false, backoffRemainingMs: 0 }; + + it("does NOT proactively wake a camera that has never streamed (empty cache)", () => { + // Empty cache is the post-reload state for the whole fleet; waking on it + // would stampede every camera (incl. disabled/dead) on every restart. + expect(shouldRefreshThumbnail(base)).toBe(false); + }); + + it("refreshes when the cache is older than the threshold", () => { + expect( + shouldRefreshThumbnail({ + ...base, + cacheAgeMs: THUMBNAIL_REFRESH_THRESHOLD_MS + 1, + }), + ).toBe(true); + }); + + it("does NOT refresh a fresh cache", () => { + expect( + shouldRefreshThumbnail({ ...base, cacheAgeMs: 60_000 }), + ).toBe(false); + }); + + it("never refreshes while the HomeBase slot is busy (yields to live)", () => { + expect( + shouldRefreshThumbnail({ ...base, cacheAgeMs: null, slotBusy: true }), + ).toBe(false); + }); + + it("never refreshes while in failure backoff (dead/asleep camera)", () => { + expect( + shouldRefreshThumbnail({ + ...base, + cacheAgeMs: null, + backoffRemainingMs: 5000, + }), + ).toBe(false); + }); +}); + +describe("resolveRefreshChoice", () => { + it("defaults to 2 hours when unset or unknown", () => { + expect(resolveRefreshChoice(undefined)).toBe(2 * 60 * 60 * 1000); + expect(resolveRefreshChoice("nonsense")).toBe(2 * 60 * 60 * 1000); + expect(resolveRefreshChoice(undefined)).toBe(THUMBNAIL_REFRESH_THRESHOLD_MS); + }); + + it("maps named choices to durations", () => { + expect(resolveRefreshChoice("30 minutes")).toBe(30 * 60 * 1000); + expect(resolveRefreshChoice("1 hour")).toBe(60 * 60 * 1000); + expect(resolveRefreshChoice("4 hours")).toBe(4 * 60 * 60 * 1000); + }); + + it("returns null for Off (disabled)", () => { + expect(resolveRefreshChoice("Off")).toBeNull(); + }); +}); + +describe("nextRefreshBackoffMs", () => { + it("grows exponentially from the base", () => { + expect(nextRefreshBackoffMs(1, 10, 10000)).toBe(10); + expect(nextRefreshBackoffMs(2, 10, 10000)).toBe(20); + expect(nextRefreshBackoffMs(3, 10, 10000)).toBe(40); + }); + + it("caps the backoff", () => { + expect(nextRefreshBackoffMs(100, 10, 1000)).toBe(1000); + }); + + it("treats zero/negative failures as one", () => { + expect(nextRefreshBackoffMs(0, 10, 10000)).toBe(10); + }); +}); diff --git a/packages/eufy-stream-server/src/jmuxer.d.ts b/packages/eufy-stream-server/src/jmuxer.d.ts index a56c5e2..7190f1b 100644 --- a/packages/eufy-stream-server/src/jmuxer.d.ts +++ b/packages/eufy-stream-server/src/jmuxer.d.ts @@ -7,6 +7,13 @@ declare module "jmuxer" { export interface JMuxerOptions { mode?: "both" | "video" | "audio"; + /** + * Either "H264" or "H265". Defaults to "H264" in JMuxer itself; must + * be set to "H265" when feeding HEVC bitstreams or the muxer writes an + * AVCC sample description over HEVC NAL units and the output fMP4 is + * undecodable. + */ + videoCodec?: "H264" | "H265"; fps?: number; flushingTime?: number; clearBuffer?: boolean; diff --git a/packages/eufy-stream-server/src/stream-server.ts b/packages/eufy-stream-server/src/stream-server.ts index 7f4c78e..5ad5488 100644 --- a/packages/eufy-stream-server/src/stream-server.ts +++ b/packages/eufy-stream-server/src/stream-server.ts @@ -44,6 +44,51 @@ export interface StreamServerOptions { wsClient: EufyWebSocketClient; /** Device serial number to filter events (required for Eufy cameras) */ serialNumber: string; + /** + * Codec hint to use BEFORE live metadata is captured. Live metadata only + * arrives after the first video frame, but downstream consumers + * (Rebroadcast plugin, HomeKit) read `getVideoMetadata()` synchronously + * when `getVideoStream()` is called — before any frame has been + * received. If we report the wrong codec, the Rebroadcast prebuffer's + * sync-frame detection is set up for the wrong NAL unit types and + * never finds a keyframe (Eufy H.265 cameras → "Unable to find sync + * frame in rtsp prebuffer" → HomeKit timeout). + * + * The device layer persists the last-detected codec to Scrypted device + * storage and passes it here on instantiation. The captured live + * metadata replaces this hint as soon as the first frame arrives. + */ + initialVideoCodec?: "H264" | "H265"; + /** + * Optional gate for the shared HomeBase stream slot. A Eufy HomeBase serves + * only one camera P2P stream at a time, so the device layer injects this to + * serialize starts across cameras on the same station. Called synchronously + * right before `startLivestream`: + * - priority "live" (a viewer/recorder is attached) always succeeds, + * preempting any current holder (whose `onRevoke` fires). + * - priority "background" (thumbnail refresh) returns null if the slot is + * busy — the server then does NOT start. + * Returns a lease whose `release()` must be called when the stream stops. + * When omitted (e.g. the CLI), the server starts unconditionally as before. + */ + acquireStreamSlot?: ( + priority: "live" | "background", + onRevoke: () => void, + ) => StationSlotLease | null; +} + +/** A held lease on a station's single stream slot. */ +export interface StationSlotLease { + release(): void; + readonly active: boolean; + /** + * Resolves when it is safe to start streaming — i.e. any camera this grant + * preempted has released the HomeBase slot (or a safety timeout elapsed). + * Already resolved when nothing was preempted. + */ + readonly whenReady: Promise; + /** Mark this camera as delivering video (protects it from preemption). */ + markDelivering(): void; } /** @@ -69,8 +114,11 @@ export interface StreamServerOptions { */ export class StreamServer extends EventEmitter { private logger: Logger; - private options: Required> & { + private options: Required< + Omit + > & { logger?: Logger; + acquireStreamSlot?: StreamServerOptions["acquireStreamSlot"]; }; private server?: net.Server; private muxedServer?: net.Server; @@ -78,11 +126,45 @@ export class StreamServer extends EventEmitter { * Map of muxed-client socket → its dedicated JMuxer instance. Each * connection gets its own muxer so every consumer receives a complete * fMP4 init segment at the start of its stream. + * + * JMuxer must be constructed with the correct `videoCodec` (H.264 vs + * H.265) because the codec choice affects which remuxer / fMP4 sample + * description box it writes (`avcC` vs `hvcC`). The codec isn't known + * until the first video event arrives, so a muxed client connecting + * before the first frame is held in `pendingMuxerSockets` instead — + * counts as an active consumer (so the upstream livestream starts) but + * has no JMuxer yet. */ private muxerStreams = new Map< net.Socket, { muxer: JMuxer; duplex: Duplex } >(); + private pendingMuxerSockets = new Set(); + + /** + * Count every "thing currently waiting on or consuming the livestream": + * • Raw TCP clients (legacy snapshot, raw video). + * • Active muxer clients (rebroadcast ffmpeg consuming fMP4). + * • Pending muxer sockets (rebroadcast ffmpeg connected, codec + * metadata not yet known so the muxer isn't constructed). + * • Pending snapshot resolvers (a Camera.takePicture call waiting + * for the next keyframe). + * + * Used everywhere we decide "is anyone still waiting on bytes" — + * watchdog gating, idle-stop gating, post-snapshot linger expiration, + * post-recycle re-arming. Excluding snapshot resolvers caused the + * activity monitor's idle-stop to tear down the livestream during a + * battery-camera cold-start while a snapshot was still waiting, which + * cost ~30s of wasted cold-start time on the follow-up consumer. + */ + private getTotalConsumers(): number { + return ( + this.connectionManager.getActiveConnectionCount() + + this.muxerStreams.size + + this.pendingMuxerSockets.size + + this.snapshotResolvers.length + ); + } private connectionManager: ConnectionManager; private h264Parser: H264Parser; private isActive = false; @@ -94,18 +176,177 @@ export class StreamServer extends EventEmitter { private livestreamIntendedState = false; private livestreamActualState = false; private startStopTimeout?: ReturnType; + /** + * Counts back-to-back `startLivestream` attempts that produced no video + * data. Resets to 0 once `livestreamActualState` flips true or the + * intended state is cleared. Used to cap the retry loop so a wedged + * upstream (e.g. HomeBase that's accepting CMD_START_REALTIME_MEDIA but + * not returning any P2P data) doesn't get hammered indefinitely — each + * extra `startLivestream` we send to a stuck HomeBase compounds the + * backpressure and slows recovery. + */ + private consecutiveNoDataStarts = 0; + /** + * How many `startLivestream` commands with no resulting video data we + * tolerate before declaring the upstream wedged. Set to 1: re-sending + * `startLivestream` to a deeply-idle T86P2 doesn't help (each fresh start + * tends to reset the half-open P2P negotiation), whereas a station P2P + * recycle reliably does. So we send once, wait the full cold-start window + * (`startStopTimeout` below uses `COLD_START_STALE_THRESHOLD_MS`), then go + * straight to the recycle instead of hammering 3× and burning ~90s first. + */ + private readonly MAX_NO_DATA_STARTS = 1; + + /** + * Timestamp of the most recent `LIVESTREAM_VIDEO_DATA` event for this + * device. Used by the mid-session wedge watchdog: if intent is "stream + * should be flowing" but no bytes have arrived for STALE_DATA_THRESHOLD_MS + * while consumers are still attached, we treat the upstream as wedged + * and emit `upstreamWedged` (same path as the cold-start counter). + * + * Reset to 0 whenever the stream is intentionally stopped so a stale + * timestamp can't trigger the watchdog on the next session. + */ + private lastVideoDataAt = 0; + /** + * Mid-session threshold: data WAS flowing in this session + * (`lastVideoDataAt > 0`) and has now stopped. 15s is plenty — a + * working stream should never have a 15s data gap. Fires the wedge + * fast so the station recycle path can recover the session quickly. + */ + private readonly STALE_DATA_THRESHOLD_MS = 15000; + + /** + * Cold-start threshold: no data has EVER arrived in this session + * (`lastVideoDataAt === 0`), so we're waiting for the first frame. + * + * This is gated by the VIEWER'S patience, not the camera's wake time. + * HomeKit kills a live session ~30s after the request if no video has + * arrived (observed: "streaming session killed, duration: 30s"). The one + * thing that reliably recovers a deeply-idle/wedged P2P session is a + * station recycle — and on the cameras that actually stream here (Front + * Door, garages) the camera itself wakes fast: it delivers its first + * frame within ~1–2s AFTER the recycle. So the entire pre-recycle wait is + * dead time. At 45s the recycle landed AFTER HomeKit had already given up + * (and torn down the prebuffer ffmpeg output → "Connection refused"), so + * the recovered video reached nobody. + * + * 18s leaves a healthy-but-cold camera room to deliver its first frame + * with no recycle at all, yet still fits detect (~18s) + recycle/restart/ + * first-frame (~10s) ≈ 28s inside HomeKit's ~30s window, so the recovered + * stream reaches the viewer that's still waiting. A genuinely deep-sleep + * battery camera that needs >30s to wake was going to miss this live view + * regardless of how long we wait; recycling early still warms the keyframe + * cache + P2P session for the next tap. Signal-dead cameras can't loop on + * this — recycle suppression (no-signal / chronic-failure) stops them. + */ + private readonly COLD_START_STALE_THRESHOLD_MS = 18000; + + /** + * Timestamp of when the current livestream "session" was established — + * either when we issued `startLivestream` or when `ensureLivestreamState` + * observed bropat reporting `isLivestreaming=true` while we have intent. + * + * The mid-session wedge watchdog uses `max(lastVideoDataAt, livestreamSessionStartedAt)` + * as its freshness anchor, so the wedge can fire even on the "zombie + * already-running" case where bropat says streaming is active but no + * `LIVESTREAM_VIDEO_DATA` events ever arrive. Without this anchor the + * watchdog skipped firing whenever `lastVideoDataAt === 0` (because + * nothing had ever flowed), leaving snapshots/muxers to spin for the + * full timeout against a wedged P2P session. + * + * Reset to 0 on graceful stop and in `markUpstreamWedged`. + */ + private livestreamSessionStartedAt = 0; + + /** + * Timer that holds the livestream open for a short window after a + * snapshot completes — gives HomeKit/Home app time to follow up with a + * stream request without paying the full cold-start penalty (~30s on + * battery cameras like the T86P2 4G LTE). HomeKit's flow is reliably + * "snapshot, then stream within seconds" when the user taps a tile. + * + * Cancelled the moment a consumer attaches (the consumer will keep the + * stream alive on its own merit) OR when the next snapshot starts (it's + * using the same warm session). Battery cost: at most LINGER_MS of + * livestream per snapshot when no consumer follows up. + */ + private postSnapshotLingerTimer?: ReturnType; + private readonly POST_SNAPSHOT_LINGER_MS = 8000; + + /** + * Set by the device layer while a station P2P recycle is in flight + * (station.disconnect → station.connect → wait for CONNECTED event). + * While true, `ensureLivestreamState` defers any `startLivestream` + * command — sending one to a recovering station typically wastes the + * attempt because bropat will accept the command but the underlying + * P2P transport isn't ready to deliver frames. When the flag clears, + * `setRecycleInFlight(false)` re-arms the livestream if consumers are + * still waiting so the user gets data without having to retry. + */ + private recycleInFlight = false; // Video metadata from first frame private videoMetadata: VideoMetadata | null = null; + /** + * Codec hint provided at construction time (see `StreamServerOptions.initialVideoCodec`). + * Returned by `getVideoMetadata()` (as a synthetic metadata object with + * codec only, dimensions/fps zeroed) ONLY when no real live metadata + * has been captured yet. The first live `LIVESTREAM_VIDEO_DATA` event + * replaces this with full real metadata. + */ + private hintedVideoCodec?: "H264" | "H265"; private metadataReceived = false; // Audio metadata from first audio frame private audioMetadata: AudioMetadata | null = null; + /** + * Whether this camera actually delivers an audio track. Many Eufy cameras + * run with the mic disabled (`microphone: false`) and send video only. + * JMuxer in `both` mode never emits fMP4 until EVERY declared track has a + * sample (see remux `isReady()`), so muxing a video-only camera as `both` + * hangs forever and the downstream ffmpeg times out. We detect audio + * empirically (set true the first time any audio frame arrives) and pick + * the muxer mode accordingly. `undefined` = not yet determined. + */ + private deliversAudio: boolean | undefined = undefined; + + /** + * How long a `both`-mode muxer may produce zero fMP4 output before we give up + * on audio and rebuild it video-only. A camera with a real, continuous audio + * track emits in well under a second; this only fires for cameras that report + * audio but don't actually deliver a usable track (which would otherwise hang + * the live view forever). Kept short so it still lands inside the consumer's + * patience window after the handoff/cold-start latency. + */ + private readonly BOTH_TO_VIDEO_FALLBACK_MS = 4000; + + /** + * Lease on the shared HomeBase stream slot (see + * `StreamServerOptions.acquireStreamSlot`). Held while this camera is the + * one streaming on its station; released when the stream stops. + */ + private streamLease: StationSlotLease | null = null; + /** + * Set when a higher-priority camera on the same HomeBase preempted us. While + * true we stay down (don't re-start) despite lingering consumers and don't + * treat the resulting "no video" as an upstream wedge. Cleared once our + * consumers drain (the viewer gave up) so a future tap starts fresh. + */ + private slotRevoked = false; + // Client activity monitoring for battery optimization private lastClientActivity = 0; private activityCheckInterval?: ReturnType; - private readonly ACTIVITY_TIMEOUT = 30000; // 30 seconds of no activity + // How long to keep a battery camera streaming after the last consumer + // detaches (e.g. you close the Home app). `lastClientActivity` advances on + // every frame WHILE a muxer is attached, so this never trips during active + // viewing — it only governs the post-close drain. Kept long enough to reuse + // a warm stream across Scrypted Rebroadcast's quick reconnect churn (~1-2s) + // and avoid a cold "find sync frame" on a fast reopen, but short enough not + // to burn battery for 30s after every view. 12s balances both. + private readonly ACTIVITY_TIMEOUT = 12000; // Statistics private stats = { @@ -128,6 +369,19 @@ export class StreamServer extends EventEmitter { private cachedPPS: Buffer | null = null; private cachedVPS: Buffer | null = null; // H.265 Video Parameter Set + // Last decodable keyframe, retained so snapshots/thumbnails can be served + // without waking the camera. Populated whenever a keyframe flows through — + // from live view, HKSV recording, a motion-triggered stream, or a prior + // snapshot — so the Home app grid can be served instantly from cache + // instead of forcing one cold P2P wake per camera (which all contend on the + // single HomeBase and mostly time out, leaving stale tiles). The buffer is + // self-contained: parameter sets are prepended so it decodes on its own. + private lastKeyframe: { + data: Buffer; + codec: "H264" | "H265"; + timestamp: number; + } | null = null; + constructor(options: StreamServerOptions) { super(); @@ -139,8 +393,12 @@ export class StreamServer extends EventEmitter { logger: options.logger, wsClient: options.wsClient, serialNumber: options.serialNumber, + initialVideoCodec: options.initialVideoCodec ?? "H264", + acquireStreamSlot: options.acquireStreamSlot, }; + this.hintedVideoCodec = options.initialVideoCodec; + // Use external logger if provided, otherwise create internal tslog Logger // Note: When external logger is provided, it controls its own debug level this.logger = @@ -240,20 +498,63 @@ export class StreamServer extends EventEmitter { // Clean up any stale connections first this.cleanupStaleConnections(); - // Total consumer count = TCP video clients (snapshot, raw video) + - // in-process muxer clients (fMP4 over the muxed port). Without - // counting the muxers here the activity timer was killing the - // livestream whenever the muxer was the only consumer, which broke - // long-lived downstream rebroadcast sessions. - const totalConsumers = - this.connectionManager.getActiveConnectionCount() + - this.muxerStreams.size; - - if (timeSinceActivity > this.ACTIVITY_TIMEOUT && totalConsumers === 0) { + const totalConsumers = this.getTotalConsumers(); + + // Mid-session wedge detection. Runs BEFORE the idle-stop check so + // that the wedge path (which clears intent + emits recycle signal) + // takes precedence over a graceful inactivity stop. + // + // Freshness anchor = max(lastVideoDataAt, livestreamSessionStartedAt). + // Using sessionStartedAt as a fallback means we also catch the + // "zombie already-running" case: bropat reports `isLivestreaming=true` + // so `ensureLivestreamState` doesn't issue a fresh `startLivestream` + // (and therefore doesn't increment the cold-start counter), but no + // video data ever arrives. Without this anchor, `lastVideoDataAt` + // stayed at 0 and the watchdog skipped firing — snapshots and + // HomeKit sessions would hang for the full 60s/30s timeout. + // + // Battery-safe gating, in order: + // 1. `livestreamIntendedState === true` — we want a stream. + // 2. `anchor > 0` — a session is established + // (so we don't false-fire before anything has happened). + // 3. `now - anchor > STALE_DATA_THRESHOLD_MS` — data has not + // flowed (or has stopped flowing) for too long. + // 4. `totalConsumers > 0` — somebody is actually + // waiting on bytes. With zero consumers the existing + // inactivity stop below handles cleanup more gracefully. + const freshnessAnchor = Math.max( + this.lastVideoDataAt, + this.livestreamSessionStartedAt, + ); + const staleMs = now - freshnessAnchor; + // Pick the right threshold for the situation. See the comments + // on `STALE_DATA_THRESHOLD_MS` and `COLD_START_STALE_THRESHOLD_MS` + // for the rationale. + const isColdStart = this.lastVideoDataAt === 0; + const threshold = isColdStart + ? this.COLD_START_STALE_THRESHOLD_MS + : this.STALE_DATA_THRESHOLD_MS; + if ( + this.livestreamIntendedState && + !this.slotRevoked && + freshnessAnchor > 0 && + staleMs > threshold && + totalConsumers > 0 + ) { + this.markUpstreamWedged("data-flow-stale", { + staleMs, + consumers: totalConsumers, + }); + } else if ( + timeSinceActivity > this.ACTIVITY_TIMEOUT && + totalConsumers === 0 + ) { this.logger.info( `🕒 No client activity for ${Math.round(timeSinceActivity / 1000)}s and no active clients, stopping camera stream`, ); this.livestreamIntendedState = false; + this.lastVideoDataAt = 0; + this.livestreamSessionStartedAt = 0; this.stopActivityMonitoring(); this.ensureLivestreamState(); } else if (totalConsumers === 0 && this.livestreamIntendedState) { @@ -358,12 +659,20 @@ export class StreamServer extends EventEmitter { // Mark livestream as actually running when we receive data if (!this.livestreamActualState) { - this.livestreamActualState = true; + this.setLivestreamActual(true); + this.consecutiveNoDataStarts = 0; this.logger.info( "📹 Livestream confirmed active - receiving video data", ); } + // Kick the stale-data watchdog. Unconditional — we want to track + // upstream liveness regardless of whether anyone is consuming the + // bytes downstream. (Bropat pushes events whenever the camera + // delivers; if those events stop while we still want a stream, + // the bropat session is wedged.) + this.lastVideoDataAt = Date.now(); + // Log video data events based on client activity const activeClients = this.connectionManager.getActiveConnectionCount(); if (activeClients > 0) { @@ -442,17 +751,28 @@ export class StreamServer extends EventEmitter { audioFrameCount++; } + // Eufy delivers AAC pre-wrapped in ADTS — JMuxer consumes ADTS + // directly. Anything else (e.g. AudioSpecificConfig, the 2-byte codec + // config packet that arrives ahead of the first frame) is dropped + // because synthesizing an ADTS header without the real sample + // rate/channel count would produce a stream the decoder misinterprets. + const isAdts = this.isAdtsFrame(audioBuffer); + + // Only a real ADTS frame counts as "this camera delivers audio". JMuxer + // `both` mode never emits a byte until it has an actual audio SAMPLE, so + // a camera that sends audio events but no usable ADTS (seen on some + // models — only the config packet arrives) would hang the muxer forever + // and the live view would stay black. Gating on ADTS means such a camera + // is detected as video-only and muxed in `video` mode instead. Set this + // before the no-muxer early-return so detection works even when the + // first ADTS frame arrives before any muxer client has attached. + if (isAdts) this.deliversAudio = true; + if (this.muxerStreams.size === 0) { return; } - // Eufy delivers AAC pre-wrapped in ADTS — JMuxer consumes ADTS - // directly. Anything else (e.g. AudioSpecificConfig, which is the - // 2-byte codec config packet that arrives ahead of the first frame) - // is dropped because synthesizing an ADTS header without knowing - // the actual sample rate/channel count would produce a stream the - // decoder would misinterpret. - if (!this.isAdtsFrame(audioBuffer)) { + if (!isAdts) { return; } @@ -478,6 +798,164 @@ export class StreamServer extends EventEmitter { return data.length >= 7 && data[0] === 0xff && (data[1] & 0xf0) === 0xf0; } + /** + * Called by the device layer around a station P2P recycle. + * + * When set to `true`, defers further `startLivestream` commands (see + * the comment on `recycleInFlight`). When cleared to `false`, if + * consumers are still attached, re-trigger `ensureLivestreamState` so + * the user automatically gets a stream as soon as the bropat P2P + * session is back up — they don't have to retry HomeKit. + * + * Safe to call multiple times with the same value; only state + * transitions perform work. + */ + setRecycleInFlight(value: boolean): void { + if (this.recycleInFlight === value) return; + this.recycleInFlight = value; + + if (value) { + this.logger.info( + "🧊 Stream server entering recycle-in-flight state — startLivestream deferred", + ); + return; + } + + const totalConsumers = this.getTotalConsumers(); + + this.logger.info( + `🔥 Stream server exiting recycle-in-flight state (consumers: ${totalConsumers})`, + ); + + if (totalConsumers > 0) { + // Consumers are waiting on a stream that we deferred. Re-arm + // intent and kick off a fresh start. The counter was already + // cleared by markUpstreamWedged so this attempt starts clean. + this.livestreamIntendedState = true; + this.lastClientActivity = Date.now(); + this.startActivityMonitoring(); + this.ensureLivestreamState().catch((e) => + this.logger.warn(`Post-recycle ensureLivestreamState failed: ${e}`), + ); + } + } + + /** + * Signal that the upstream P2P session is wedged. Two callers: + * - cold-start: `consecutiveNoDataStarts` reached `MAX_NO_DATA_STARTS` + * (3 fresh `startLivestream` attempts produced zero video bytes). + * - mid-session: data was flowing, then stopped for more than + * `STALE_DATA_THRESHOLD_MS` while consumers still want a stream. + * + * Resets every piece of state that could cause us to keep poking the + * upstream: livestream intent, the cold-start counter, the data-flow + * watchdog, and the in-flight start/stop timeout. We deliberately do + * NOT auto-restart — the listener (eufy-device.ts) recycles the bropat + * station P2P session, and the next consumer that attaches will trigger + * a fresh livestream organically. This keeps the camera from being + * woken unnecessarily when no one is actually watching. + */ + private markUpstreamWedged( + reason: "cold-start-counter-maxed" | "data-flow-stale", + detail: { attempts?: number; staleMs?: number; consumers?: number }, + ): void { + if (reason === "cold-start-counter-maxed") { + this.logger.error( + `❌ Giving up after ${detail.attempts} consecutive startLivestream attempts with no data — upstream P2P (HomeBase/station) appears wedged. Will not auto-retry until a fresh consumer attaches.`, + ); + } else { + this.logger.error( + `❌ Mid-session wedge: ${detail.staleMs}ms since last video data while ${detail.consumers} consumer(s) attached — upstream P2P appears wedged. Will not auto-retry until a fresh consumer attaches.`, + ); + } + + this.livestreamIntendedState = false; + this.setLivestreamActual(false); + this.releaseStreamSlot(); + this.consecutiveNoDataStarts = 0; + this.lastVideoDataAt = 0; + this.livestreamSessionStartedAt = 0; + this.cancelPostSnapshotLinger("upstream wedged"); + if (this.startStopTimeout) { + clearTimeout(this.startStopTimeout); + this.startStopTimeout = undefined; + } + + this.emit("upstreamWedged", { + serialNumber: this.options.serialNumber, + reason, + ...detail, + }); + this.emit( + "streamError", + new Error( + reason === "cold-start-counter-maxed" + ? "Upstream livestream not delivering data after multiple attempts" + : "Upstream livestream stopped delivering data mid-session", + ), + ); + } + + /** Release our HomeBase stream-slot lease, if held. Idempotent. */ + private releaseStreamSlot(): void { + if (this.streamLease) { + this.streamLease.release(); + this.streamLease = null; + } + } + + /** + * Invoked (synchronously, by the coordinator) when a higher-priority camera + * on the same HomeBase preempts our slot. Stop our livestream and stay down + * — WITHOUT recycling, since this is contention, not a wedge — until our own + * consumers drain. The slot already belongs to the preemptor at this point. + */ + private handleSlotRevoked(): void { + if (this.slotRevoked) return; + this.logger.info( + "↩️ HomeBase stream slot revoked by another camera — yielding", + ); + this.slotRevoked = true; + this.livestreamIntendedState = false; + // Stop our P2P livestream FIRST, then release the slot — do NOT release + // synchronously here. The preemptor's `whenReady` is gated on our release, + // so releasing before our stream has actually stopped lets the preemptor + // call `startLivestream` while our P2P is still up. On the one-stream-at-a- + // time HomeBase that overlap starves the handoff: the preemptor gets a + // frame or two (enough for metadata) and then stalls, while our redundant + // stop races its start (`device_livestream_not_running`). Deferring the + // release until after our stop completes (in `ensureLivestreamState`'s stop + // branch, with the `finally` as a backstop for the already-stopped case) + // makes it a clean staggered handoff: we stop → free the HomeBase → the + // preemptor starts on an idle station. `whenReady`'s timeout still bounds + // the preemptor's wait if our stop hangs. + void this.ensureLivestreamState() + .catch((e) => + this.logger.warn(`Post-revoke ensureLivestreamState failed: ${e}`), + ) + .finally(() => this.releaseStreamSlot()); + } + + /** + * Update whether the livestream is actually delivering video, emitting a + * `livestreamActive` / `livestreamInactive` transition event (with the + * device serial) when it changes. Consumers (eufy-device.ts) use these to + * maintain the cross-camera station-stream registry that gates P2P + * recycles. Idempotent — emits only on an actual state change. + */ + private setLivestreamActual(active: boolean): void { + if (this.livestreamActualState === active) return; + this.livestreamActualState = active; + if (active) { + // We're delivering video now — protect our HomeBase slot from being + // preempted by another camera's (e.g. grid-preview) live request. + this.streamLease?.markDelivering(); + } + this.emit(active ? "livestreamActive" : "livestreamInactive", { + serialNumber: this.options.serialNumber, + }); + } + /** * Ensure the livestream is in the correct state with retry logic */ @@ -511,27 +989,109 @@ export class StreamServer extends EventEmitter { } if (this.livestreamIntendedState && !actualStreamingStatus) { + // Defer if a station P2P recycle is in flight — startLivestream + // sent during the recovery window typically lands on a station + // that bropat hasn't finished re-connecting, wasting the attempt + // and (worse) burning a slot in the cold-start counter. The + // recycle handler will re-arm the livestream when it completes + // if consumers are still waiting. + if (this.recycleInFlight) { + this.logger.info( + "⏸️ Deferring startLivestream — station P2P recycle is in flight", + ); + break; + } + + // Stop hammering a wedged upstream. After MAX_NO_DATA_STARTS + // consecutive startLivestream commands without ever receiving + // video data, give up and signal an upstream wedge. + if (this.consecutiveNoDataStarts >= this.MAX_NO_DATA_STARTS) { + this.markUpstreamWedged("cold-start-counter-maxed", { + attempts: this.consecutiveNoDataStarts, + }); + break; + } + + // If a higher-priority camera on this HomeBase preempted us, stay + // down until our own consumers drain — don't fight for the slot. + if (this.slotRevoked) { + this.logger.info( + "⏸️ Slot revoked by another camera on this HomeBase — not starting until consumers drain", + ); + break; + } + + // Acquire the shared HomeBase stream slot before starting. Only the + // slot holder ever issues startLivestream, so cameras never stampede + // the one-stream-at-a-time HomeBase. + if (this.options.acquireStreamSlot && !this.streamLease?.active) { + const priority = + this.getTotalConsumers() > 0 ? "live" : "background"; + const lease = this.options.acquireStreamSlot(priority, () => + this.handleSlotRevoked(), + ); + if (!lease) { + // Background (thumbnail refresh) denied — the slot is busy with a + // viewer/recorder. Don't wake; abandon this attempt quietly. + this.logger.info( + `⛔ HomeBase stream slot busy — deferring ${priority} start`, + ); + this.livestreamIntendedState = false; + break; + } + this.logger.info(`🎟️ Acquired HomeBase stream slot (${priority})`); + this.streamLease = lease; + // If we preempted another camera, wait for it to step off the + // shared HomeBase slot before starting, so the two don't overlap + // (which causes mutual P2P starvation and audio bleeding into the + // wrong muxer). Resolves immediately when nothing was preempted. + await lease.whenReady; + // Bail if we were ourselves preempted/torn down while waiting. + if (!this.livestreamIntendedState || this.slotRevoked) { + break; + } + } + // Need to start livestream + this.consecutiveNoDataStarts++; + this.livestreamSessionStartedAt = Date.now(); this.logger.info( - `🎥 Starting livestream (attempt ${attempt}/${maxRetries})`, + `🎥 Starting livestream (attempt ${attempt}/${maxRetries}, consecutive-no-data=${this.consecutiveNoDataStarts}/${this.MAX_NO_DATA_STARTS})`, ); await this.options.wsClient.commands .device(this.options.serialNumber) .startLivestream(); this.logger.info("✅ Livestream start command sent successfully"); - // Set timeout to check if it actually started + // Wait the full cold-start window before re-evaluating. A + // deeply-idle battery camera can legitimately take 30–45s to wake + // and deliver its first frame, so checking sooner just provokes a + // premature re-send/recycle. With MAX_NO_DATA_STARTS=1 this + // re-entry will conclude the upstream is wedged (no data after a + // full window) and hand off to the station P2P recycle. this.startStopTimeout = setTimeout(() => { if (this.livestreamIntendedState && !this.livestreamActualState) { this.logger.warn( - "⚠️ Livestream start timeout - no video data received, will retry", + `⚠️ No video data ${this.COLD_START_STALE_THRESHOLD_MS / 1000}s after startLivestream — escalating to wedge/recycle`, ); this.ensureLivestreamState(); } - }, 30000); // 30 seconds to receive first video data + }, this.COLD_START_STALE_THRESHOLD_MS); } else if (this.livestreamIntendedState && actualStreamingStatus) { - // Stream is already running and we want it running - all good - this.logger.debug("Livestream already running as desired"); + // Stream is already running and we want it running - all good. + // Set the session anchor if it's not already set so the + // mid-session watchdog can fire on the "zombie already-running" + // case (bropat reports streaming but no LIVESTREAM_VIDEO_DATA + // events ever arrive — no startLivestream command means no + // cold-start counter increment to catch it the other way). + if (this.livestreamSessionStartedAt === 0) { + this.livestreamSessionStartedAt = Date.now(); + this.logger.info( + "📡 Bropat reports livestream already active — anchoring session for wedge watchdog", + ); + } else { + this.logger.debug("Livestream already running as desired"); + } } else if (!this.livestreamIntendedState && actualStreamingStatus) { // Need to stop livestream this.logger.info( @@ -541,15 +1101,43 @@ export class StreamServer extends EventEmitter { .device(this.options.serialNumber) .stopLivestream(); this.logger.info("✅ Livestream stop command sent successfully"); - this.livestreamActualState = false; + this.setLivestreamActual(false); + this.releaseStreamSlot(); + // Clear the stale-data watchdog timestamp on graceful stop so + // the next session's watchdog starts fresh (won't false-fire + // from a previous session's last-data timestamp). + this.lastVideoDataAt = 0; + this.livestreamSessionStartedAt = 0; } else { - // Stream is not running and we don't want it running - all good + // Stream is not running and we don't want it running - all good. + // Still flip actual→false: bropat may have stopped the stream on + // its own (so we never hit the explicit stop branch above), and + // leaving `livestreamActualState` stuck true would leak a stale + // "active" entry into the cross-camera station registry. + this.setLivestreamActual(false); this.logger.debug("Livestream already stopped as desired"); } // Success - break out of retry loop break; } catch (error: any) { + // Stopping a stream that isn't actually running is a no-op success, + // not a failure. This happens when we yield/stop a camera that was + // told to start but never delivered (e.g. a preempted dead camera): + // bropat reports it "livestreaming" but stop_livestream then fails + // with device_livestream_not_running. Treat it as already stopped. + const msg = String(error?.message || error); + if (msg.includes("device_livestream_not_running")) { + this.logger.info( + "Livestream already stopped upstream (not running) — treating as success", + ); + this.setLivestreamActual(false); + this.releaseStreamSlot(); + this.lastVideoDataAt = 0; + this.livestreamSessionStartedAt = 0; + break; + } + this.logger.warn( `❌ Livestream command failed (attempt ${attempt}/${maxRetries}):`, error.message || error, @@ -638,53 +1226,164 @@ export class StreamServer extends EventEmitter { * meaningfully faster than the previous ffmpeg-subprocess approach and * matches what the Eufy cameras actually deliver byte-for-byte. */ - private handleMuxedClient(socket: net.Socket): void { + private async handleMuxedClient(socket: net.Socket): Promise { + // Mark this socket as a pending consumer immediately so the upstream + // livestream starts even though we don't yet have a JMuxer to feed. + // `updateLivestreamStateForMuxerClients` counts both pending and active + // muxers as consumers. + this.pendingMuxerSockets.add(socket); + + const pendingCleanup = () => { + this.pendingMuxerSockets.delete(socket); + }; + socket.on("close", pendingCleanup); + socket.on("error", pendingCleanup); + + // Kick off the upstream livestream (no-op if already running). + this.updateLivestreamStateForMuxerClients(); + + // Wait for the codec to be known via the first video event's metadata. + // Defaults to H.264 if the camera/stream never delivers metadata in + // time — matches the previous (silently-wrong-for-H.265) behaviour + // rather than dropping the client. + // Default to the construction-time hint (persisted from the last + // detected codec for this device). Without this, the muxer would + // build an avcC sample description (H.264) for an H.265 camera that + // happens to time out the metadata wait — producing un-decodable + // fMP4 the moment H.265 data does arrive. Falls back to H.264 only + // if there's no hint either. + let videoCodec: "H264" | "H265" = this.hintedVideoCodec ?? "H264"; + try { + // 60s — battery cameras (T8170 S340 sleep mode, T86P2 4G LTE cold-start) + // can take 30–45s to deliver their first IDR after startLivestream. + const metadata = await this.waitForVideoMetadata(60000); + const c = metadata.videoCodec.toUpperCase(); + videoCodec = c === "H265" || c === "HEVC" ? "H265" : "H264"; + } catch (e) { + this.logger.warn( + `Muxer client: timed out waiting for video metadata, falling back to hinted codec ${videoCodec}. ${e}`, + ); + } + + // Socket may have given up while we waited. + if (socket.destroyed || !this.pendingMuxerSockets.has(socket)) { + this.pendingMuxerSockets.delete(socket); + return; + } + const videoFps = this.videoMetadata?.videoFPS ?? 15; - // Always declare both tracks. The muxed client connects BEFORE the - // first audio frame arrives from Eufy, so `audioMetadata` is null at - // this point on a cold start; if we picked mode based on it we'd lock - // in video-only and silently drop every audio frame thereafter. - // JMuxer's `both` mode correctly holds audio until the video track is - // ready, then emits both tracks into the fMP4 moov. - const mode = "both"; - - const muxer = new JMuxer({ - mode, - fps: videoFps, - flushingTime: 0, - clearBuffer: false, - debug: false, - }); - const duplex: Duplex = muxer.createStream(); - let firstChunkLogged = false; - duplex.on("data", (chunk: Buffer) => { - if (!firstChunkLogged) { - this.logger.info( - `JMuxer emitting fMP4 (first chunk: ${chunk.length} bytes, mode=${mode}, fps=${videoFps})`, - ); - firstChunkLogged = true; + // Pick the muxer mode by whether this camera actually delivers audio. + // JMuxer `both` will not emit a single fMP4 byte until BOTH the video + // AND audio tracks have a sample (remux `isReady()`), so a mic-off, + // video-only camera muxed as `both` hangs forever and the downstream + // ffmpeg dies with "timeout waiting for data" — live view never starts. + // + // If we've already seen audio from this device, use `both`. If we know + // it's video-only, use `video`. If we don't know yet (first stream, + // muxer connects before the first audio frame), briefly wait for an + // audio frame now that video is confirmed flowing — a mic-on camera + // delivers audio within ~1-2s of video; if none arrives, it's video-only. + // The socket stays in `pendingMuxerSockets` during this wait so it still + // counts as a consumer (keeping the livestream alive). + if (this.deliversAudio === undefined) { + const audioDeadline = Date.now() + 2500; + while (Date.now() < audioDeadline && this.deliversAudio === undefined) { + if (socket.destroyed) { + this.pendingMuxerSockets.delete(socket); + return; + } + await new Promise((r) => setTimeout(r, 150)); } - if (!socket.destroyed) socket.write(chunk); - }); - duplex.on("error", (err) => { - this.logger.warn(`JMuxer duplex error: ${err.message}`); - }); + if (this.deliversAudio === undefined) this.deliversAudio = false; + } + + // Now graduate the socket from pending → active muxer. + this.pendingMuxerSockets.delete(socket); + const mode: "both" | "video" = this.deliversAudio ? "both" : "video"; + this.logger.info( + `Muxer mode=${mode} (deliversAudio=${this.deliversAudio}) for ${this.options.serialNumber}`, + ); + + let emittedFirstChunk = false; + let bothFallbackTimer: ReturnType | undefined; + + // Build (or rebuild) the JMuxer for this socket in the given mode and wire + // its output to the socket. Replacing the entry in `muxerStreams` is how the + // `both`→`video` fallback swaps modes without dropping the client. + const buildMuxer = (useMode: "both" | "video"): void => { + const muxer = new JMuxer({ + mode: useMode, + videoCodec, + fps: videoFps, + flushingTime: 0, + clearBuffer: false, + debug: false, + }); + const duplex: Duplex = muxer.createStream(); + duplex.on("data", (chunk: Buffer) => { + if (!emittedFirstChunk) { + emittedFirstChunk = true; + if (bothFallbackTimer) { + clearTimeout(bothFallbackTimer); + bothFallbackTimer = undefined; + } + this.logger.info( + `JMuxer emitting fMP4 (first chunk: ${chunk.length} bytes, mode=${useMode}, codec=${videoCodec}, fps=${videoFps})`, + ); + } + if (!socket.destroyed) socket.write(chunk); + }); + duplex.on("error", (err) => { + this.logger.warn(`JMuxer duplex error: ${err.message}`); + }); + this.muxerStreams.set(socket, { muxer, duplex }); + }; - this.muxerStreams.set(socket, { muxer, duplex }); + buildMuxer(mode); this.logger.info( - `Muxed client attached (total active muxers: ${this.muxerStreams.size})`, + `Muxed client attached (codec=${videoCodec}, total active muxers: ${this.muxerStreams.size})`, ); - // This is the first consumer of the stream — bring up the livestream - // if the stream server's TCP video clients haven't already started it. + // `both` mode never emits a single fMP4 byte until JMuxer has BOTH a video + // keyframe and an audio sample (remux `isReady()`). Some cameras report + // audio (a stray ADTS frame flips `deliversAudio`) but don't actually + // deliver a continuous audio track once muxing starts — so `both` waits + // forever and the live view stays black. Guarantee video by rebuilding the + // muxer video-only if no output appears shortly. Video-capable cameras with + // real audio emit in well under this window, so they keep their audio. + if (mode === "both") { + bothFallbackTimer = setTimeout(() => { + bothFallbackTimer = undefined; + if (emittedFirstChunk || socket.destroyed) return; + if (!this.muxerStreams.has(socket)) return; + this.logger.warn( + `Muxer 'both' produced no fMP4 in ${this.BOTH_TO_VIDEO_FALLBACK_MS}ms — rebuilding video-only for ${this.options.serialNumber} (camera reports audio but isn't delivering a usable track)`, + ); + const existing = this.muxerStreams.get(socket); + try { + existing?.muxer.destroy(); + } catch { + // ignore — being replaced anyway + } + buildMuxer("video"); + }, this.BOTH_TO_VIDEO_FALLBACK_MS); + } + + // Re-evaluate consumer state now that the socket moved from pending → + // active. No-op if the livestream is already running. this.updateLivestreamStateForMuxerClients(); const cleanup = () => { - if (!this.muxerStreams.has(socket)) return; + if (bothFallbackTimer) { + clearTimeout(bothFallbackTimer); + bothFallbackTimer = undefined; + } + const existing = this.muxerStreams.get(socket); + if (!existing) return; this.muxerStreams.delete(socket); try { - muxer.destroy(); + existing.muxer.destroy(); } catch (e) { this.logger.warn(`JMuxer destroy threw during cleanup: ${e}`); } @@ -703,12 +1402,32 @@ export class StreamServer extends EventEmitter { * (TCP video clients + in-process muxer clients). Called on every * muxer-client attach/detach. */ + private cancelPostSnapshotLinger(reason: string): void { + if (this.postSnapshotLingerTimer) { + clearTimeout(this.postSnapshotLingerTimer); + this.postSnapshotLingerTimer = undefined; + this.logger.debug(`Cancelled post-snapshot linger: ${reason}`); + } + } + private async updateLivestreamStateForMuxerClients(): Promise { - const totalConsumers = - this.connectionManager.getActiveConnectionCount() + - this.muxerStreams.size; + const totalConsumers = this.getTotalConsumers(); + + // A consumer is attaching (or detaching). If we were lingering after a + // snapshot waiting for exactly this, cancel — the consumer's own + // lifecycle now governs the livestream. + if (totalConsumers > 0) { + this.cancelPostSnapshotLinger("consumer attached"); + } else if (this.slotRevoked) { + // Our consumers have all drained after a preemption — reset so a future + // viewer can start fresh (and re-take the slot, newest-wins). + this.slotRevoked = false; + this.logger.debug("Consumers drained after slot revoke — cleared"); + } - if (totalConsumers > 0 && !this.livestreamIntendedState) { + // While preempted, stay down despite a reconnecting consumer — don't fight + // the camera that took the HomeBase slot. + if (totalConsumers > 0 && !this.livestreamIntendedState && !this.slotRevoked) { this.livestreamIntendedState = true; this.lastClientActivity = Date.now(); this.startActivityMonitoring(); @@ -757,6 +1476,11 @@ export class StreamServer extends EventEmitter { // Stop activity monitoring this.stopActivityMonitoring(); + // Guarantee the registry sees this device as no longer streaming, even + // if the teardown below doesn't traverse the graceful-stop branch. + this.setLivestreamActual(false); + this.releaseStreamSlot(); + // Stop livestream if there are active clients const activeClients = this.connectionManager.getActiveConnectionCount(); if (activeClients > 0) { @@ -788,6 +1512,12 @@ export class StreamServer extends EventEmitter { } this.muxerStreams.clear(); + // Also close any muxer clients still waiting for first-frame metadata + for (const socket of this.pendingMuxerSockets) { + if (!socket.destroyed) socket.destroy(); + } + this.pendingMuxerSockets.clear(); + // Close muxed server if (this.muxedServer) { this.muxedServer.close(); @@ -888,17 +1618,65 @@ export class StreamServer extends EventEmitter { } }); - // Resolve any pending snapshot requests with keyframe data - // This happens BEFORE checking if server is active, so snapshots work without TCP server + // Resolve any pending snapshot requests with keyframe data. + // Snapshot requests need a decodable picture, so parameter-set-only + // events (H.264 SPS/PPS or H.265 VPS/SPS/PPS without an IRAP slice) + // don't qualify — FFmpeg can't produce a JPEG from those alone. + // H.264: require IDR (type 5). H.265: require an IRAP slice + // (types 16–23: BLA/IDR/CRA and reserved IRAP). + // + // This happens BEFORE checking if server is active, so snapshots + // work without TCP server. + const hasSnapshotKeyframe = isHevc + ? nalUnits.some((nal) => nal.type >= 16 && nal.type <= 23) + : nalUnits.some((nal) => nal.type === 5); let snapshotsHandled = false; - if (isKeyFrame && this.snapshotResolvers.length > 0) { - this.logger.debug( - `Resolving ${this.snapshotResolvers.length} snapshot request(s) with keyframe data`, - ); - const resolvers = [...this.snapshotResolvers]; - this.snapshotResolvers = []; - resolvers.forEach(({ resolve }) => resolve(data)); - snapshotsHandled = true; + if (hasSnapshotKeyframe) { + // Build a self-contained, decodable bitstream for the JPEG + // converter. H.265 IDR slices reference VPS/SPS/PPS by ID — without + // those parameter sets in the same buffer, ffmpeg can't decode and + // produces a malformed JPEG that Scrypted renders as a broken + // image icon. We prepend the cached parameter sets unless they're + // already present in this data event (Eufy sometimes bundles + // them, sometimes delivers them as separate prior events). + const types = new Set(nalUnits.map((n) => n.type)); + const parts: Buffer[] = []; + if (isHevc) { + if (!types.has(32) && this.cachedVPS) parts.push(this.cachedVPS); + if (!types.has(33) && this.cachedSPS) parts.push(this.cachedSPS); + if (!types.has(34) && this.cachedPPS) parts.push(this.cachedPPS); + } else { + if (!types.has(7) && this.cachedSPS) parts.push(this.cachedSPS); + if (!types.has(8) && this.cachedPPS) parts.push(this.cachedPPS); + } + const snapshotPayload = + parts.length > 0 ? Buffer.concat([...parts, data]) : data; + + // Retain this keyframe for cache-served snapshots/thumbnails. This + // fires for EVERY keyframe regardless of whether a snapshot is + // pending, so the cache refreshes for free whenever the camera is + // already awake (live view, HKSV, motion, or a prior snapshot). + this.lastKeyframe = { + data: snapshotPayload, + codec: isHevc ? "H265" : "H264", + timestamp: Date.now(), + }; + + // Resolve any pending snapshot requests with the same decodable buffer. + if (this.snapshotResolvers.length > 0) { + this.logger.debug( + `Resolving ${this.snapshotResolvers.length} snapshot request(s) with keyframe data`, + ); + if (parts.length > 0) { + this.logger.debug( + `Prepended ${parts.length} parameter set(s) to snapshot keyframe (${snapshotPayload.length} bytes total)`, + ); + } + const resolvers = [...this.snapshotResolvers]; + this.snapshotResolvers = []; + resolvers.forEach(({ resolve }) => resolve(snapshotPayload)); + snapshotsHandled = true; + } } // If server is not active, we've already handled snapshot resolution above @@ -953,10 +1731,29 @@ export class StreamServer extends EventEmitter { } /** - * Get video metadata from the first received frame + * Get video metadata. Returns real metadata captured from the first + * `LIVESTREAM_VIDEO_DATA` event if available, otherwise falls back to a + * synthetic record built from the construction-time codec hint. Width, + * height, and fps in the synthetic record are 0 — callers that need + * those should treat 0 as "not yet known" and use their own fallbacks. + * + * The codec field is the load-bearing one: downstream consumers (stream + * service, snapshot service) use it to advertise the correct codec to + * Scrypted's media pipeline. Reporting the wrong codec causes the + * Rebroadcast prebuffer to set up sync-frame detection for the wrong + * NAL unit types and never find a keyframe. */ getVideoMetadata(): VideoMetadata | null { - return this.videoMetadata; + if (this.videoMetadata) return this.videoMetadata; + if (this.hintedVideoCodec) { + return { + videoCodec: this.hintedVideoCodec, + videoWidth: 0, + videoHeight: 0, + videoFPS: 0, + }; + } + return null; } /** @@ -1053,6 +1850,39 @@ export class StreamServer extends EventEmitter { }; } + /** + * Return the most recently seen keyframe if it is no older than `maxAgeMs`, + * otherwise null. Lets callers serve a snapshot/thumbnail without waking a + * (battery) camera. The returned buffer is self-contained — parameter sets + * are prepended — so it decodes to a JPEG on its own. + * + * @param maxAgeMs - Maximum acceptable age of the cached keyframe, in ms + */ + getCachedKeyframe( + maxAgeMs: number, + ): { data: Buffer; codec: "H264" | "H265"; ageMs: number } | null { + if (!this.lastKeyframe) return null; + const ageMs = Date.now() - this.lastKeyframe.timestamp; + if (ageMs > maxAgeMs) return null; + return { + data: this.lastKeyframe.data, + codec: this.lastKeyframe.codec, + ageMs, + }; + } + + /** + * Seed the keyframe cache from a previously-persisted frame (e.g. restored + * from Scrypted storage after a plugin reload) so snapshots/thumbnails serve + * the last-seen image immediately, without waking the camera. Only seeds if + * the cache is currently empty (a live frame always wins). Timestamp is set + * to now so the restored frame isn't treated as stale right after a restart. + */ + setCachedKeyframe(data: Buffer, codec: "H264" | "H265"): void { + if (this.lastKeyframe) return; + this.lastKeyframe = { data, codec, timestamp: Date.now() }; + } + /** * Capture a single snapshot frame from the stream. * Starts the livestream if not already running, waits for a keyframe, @@ -1064,6 +1894,11 @@ export class StreamServer extends EventEmitter { async captureSnapshot(timeoutMs: number = 15000): Promise { this.logger.info("📸 Capturing snapshot..."); + // Cancel any pending post-snapshot stop — this snapshot is about to + // use the (possibly still-warm) livestream, so we don't want it torn + // down while we wait for a keyframe. + this.cancelPostSnapshotLinger("new snapshot starting"); + const wasStreamRunning = this.livestreamIntendedState; try { @@ -1071,6 +1906,13 @@ export class StreamServer extends EventEmitter { if (!this.livestreamIntendedState) { this.logger.debug("Starting livestream for snapshot capture"); this.livestreamIntendedState = true; + // Kick off the activity monitor so the mid-session wedge watchdog + // can fire if upstream P2P stalls. Without this, snapshots that + // started the livestream (with no muxer client attached yet) had + // no watchdog at all — a wedged session would only surface after + // the 60s snapshot timeout. + this.lastClientActivity = Date.now(); + this.startActivityMonitoring(); await this.ensureLivestreamState(); } else { this.logger.debug( @@ -1116,18 +1958,52 @@ export class StreamServer extends EventEmitter { return snapshotBuffer; } finally { - // Stop livestream if it wasn't running before - if (!wasStreamRunning) { - this.logger.debug( - "Stopping livestream after snapshot capture (was not running before)", + // Only stop the livestream if (a) we were the ones who started it + // (`!wasStreamRunning`) AND (b) no other consumer has attached during + // our snapshot wait. Without (b) we tear down the livestream out from + // under a concurrently-attached HomeKit muxer client, which causes the + // downstream Rebroadcast ffmpeg to get `Connection refused` and the + // streaming session to fail. The activity monitor will stop the + // stream gracefully once consumers detach. + const totalConsumers = this.getTotalConsumers(); + + if (!wasStreamRunning && totalConsumers === 0) { + // LINGER instead of immediate stop. The Home app pattern is a + // snapshot request followed within seconds by a stream request. + // Tearing the livestream down here forces the follow-up stream + // to pay the full cold-start cost (~30s on battery cameras), + // which often exceeds HomeKit's session timeout (~30s) and the + // stream visibly fails. Lingering briefly bridges the gap. + this.logger.info( + `⏳ Snapshot complete, no consumers — lingering livestream for ${this.POST_SNAPSHOT_LINGER_MS}ms`, ); - this.livestreamIntendedState = false; - // Don't await here to avoid blocking the snapshot return - this.ensureLivestreamState().catch((error) => { - this.logger.warn( - `Failed to stop livestream after snapshot: ${error}`, + this.postSnapshotLingerTimer = setTimeout(() => { + this.postSnapshotLingerTimer = undefined; + const consumersNow = this.getTotalConsumers(); + if (consumersNow > 0) { + this.logger.info( + `🔌 Linger expired with ${consumersNow} consumer(s) attached — keeping livestream`, + ); + return; + } + if (!this.livestreamIntendedState) { + // Someone else already cleared intent (e.g. recycle/wedge) + return; + } + this.logger.info( + "🕒 Post-snapshot linger expired, no consumers — stopping livestream", ); - }); + this.livestreamIntendedState = false; + this.ensureLivestreamState().catch((error) => { + this.logger.warn( + `Failed to stop livestream after snapshot linger: ${error}`, + ); + }); + }, this.POST_SNAPSHOT_LINGER_MS); + } else if (!wasStreamRunning && totalConsumers > 0) { + this.logger.info( + `📌 Snapshot complete but ${totalConsumers} consumer(s) attached — leaving livestream running`, + ); } } } diff --git a/packages/eufy-stream-server/tests/stream-server.test.ts b/packages/eufy-stream-server/tests/stream-server.test.ts index ac46cc8..540192d 100644 --- a/packages/eufy-stream-server/tests/stream-server.test.ts +++ b/packages/eufy-stream-server/tests/stream-server.test.ts @@ -746,6 +746,30 @@ describe("StreamServer", () => { await expect(snapshotPromise).rejects.toThrow(/timed out/i); }); + it("does NOT resolve snapshot on H.265 parameter sets without IRAP", async () => { + // Seed metadata via a parameter-set-only event (codec still gets + // detected from event.metadata). + const psOnly = Buffer.concat([ + createTestHevcVpsData(), + createTestHevcSpsData(), + createTestHevcPpsData(), + ]); + + // Begin snapshot capture + const snapshotPromise = serverWithWs.captureSnapshot(200); + await wait(50); + + // Deliver only VPS+SPS+PPS — no IRAP/IDR slice. FFmpeg can't decode + // a JPEG from parameter sets alone, so the resolver must wait. + eventHandler({ + serialNumber: "H265_DEVICE", + buffer: { data: psOnly }, + metadata: h265Metadata, + }); + + await expect(snapshotPromise).rejects.toThrow(/timed out/i); + }); + it("caches H.265 VPS, SPS and PPS and sends them to new clients", async () => { const vpsData = createTestHevcVpsData(); const spsData = createTestHevcSpsData(); @@ -808,6 +832,381 @@ describe("StreamServer", () => { }); }); + describe("station stream slot gating (acquireStreamSlot)", () => { + const makeWs = () => { + const startLivestream = jest.fn().mockResolvedValue({}); + const mockWsClient = { + addEventListener: jest.fn().mockReturnValue(() => {}), + commands: { + device: jest.fn().mockReturnValue({ + startLivestream, + stopLivestream: jest.fn().mockResolvedValue({}), + isLivestreaming: jest.fn().mockResolvedValue({ + livestreaming: false, + }), + }), + }, + }; + return { mockWsClient, startLivestream }; + }; + + it("does NOT start the livestream when a background request is denied", async () => { + const { mockWsClient, startLivestream } = makeWs(); + const acquireStreamSlot = jest.fn().mockReturnValue(null); // slot busy + const s = new StreamServer({ + port: testPort, + host: "127.0.0.1", + debug: true, + wsClient: mockWsClient as any, + serialNumber: "TEST123", + acquireStreamSlot, + }); + await s.start(); + + // captureSnapshot starts the stream at background priority (no consumers). + await expect(s.captureSnapshot(150)).rejects.toThrow(/timed out/); + + expect(acquireStreamSlot).toHaveBeenCalledWith( + "background", + expect.any(Function), + ); + expect(startLivestream).not.toHaveBeenCalled(); + await s.stop(); + }); + + it("yields (slotRevoked, no wedge/recycle) when the slot is revoked", async () => { + const { mockWsClient, startLivestream } = makeWs(); + let capturedRevoke: (() => void) | undefined; + const lease = { release: jest.fn(), active: true }; + const acquireStreamSlot = jest.fn((_p: string, onRevoke: () => void) => { + capturedRevoke = onRevoke; + return lease; + }); + const s = new StreamServer({ + port: testPort, + host: "127.0.0.1", + debug: true, + wsClient: mockWsClient as any, + serialNumber: "TEST123", + acquireStreamSlot: acquireStreamSlot as any, + }); + await s.start(); + const wedged = jest.fn(); + s.on("upstreamWedged", wedged); + + // Begin a capture so the server acquires the slot and starts. + const snap = s.captureSnapshot(400).catch(() => {}); + await wait(80); + expect(startLivestream).toHaveBeenCalled(); + expect(capturedRevoke).toBeDefined(); + + // A higher-priority camera preempts us — assert the immediate contract. + capturedRevoke!(); + await wait(30); + + expect((s as any).slotRevoked).toBe(true); + // Yielding the slot is contention, NOT an upstream wedge — must not recycle. + expect(wedged).not.toHaveBeenCalled(); + expect(lease.release).toHaveBeenCalled(); + await snap; + await s.stop(); + }); + + it("on revoke, stops the livestream BEFORE releasing the slot (clean staggered handoff)", async () => { + const order: string[] = []; + let started = false; + const startLivestream = jest.fn().mockImplementation(async () => { + started = true; + return {}; + }); + const stopLivestream = jest.fn().mockImplementation(async () => { + started = false; + order.push("stop"); + return {}; + }); + const mockWsClient = { + addEventListener: jest.fn().mockReturnValue(() => {}), + commands: { + device: jest.fn().mockReturnValue({ + startLivestream, + stopLivestream, + // Reflect reality: not streaming until start, streaming after. + isLivestreaming: jest + .fn() + .mockImplementation(async () => ({ livestreaming: started })), + }), + }, + }; + let capturedRevoke: (() => void) | undefined; + const lease = { + release: jest.fn(() => order.push("release")), + active: true, + }; + const acquireStreamSlot = jest.fn((_p: string, onRevoke: () => void) => { + capturedRevoke = onRevoke; + return lease; + }); + const s = new StreamServer({ + port: testPort, + host: "127.0.0.1", + debug: true, + wsClient: mockWsClient as any, + serialNumber: "TEST123", + acquireStreamSlot: acquireStreamSlot as any, + }); + await s.start(); + + const snap = s.captureSnapshot(400).catch(() => {}); + await wait(80); + expect(startLivestream).toHaveBeenCalled(); + expect(capturedRevoke).toBeDefined(); + + // Preempted: must stop our P2P, THEN release — so the preemptor's + // whenReady (gated on release) can't start it mid-teardown and starve + // the one-stream HomeBase. + capturedRevoke!(); + await wait(120); + + expect(order).toContain("stop"); + expect(order).toContain("release"); + expect(order.indexOf("stop")).toBeLessThan(order.indexOf("release")); + expect(stopLivestream).toHaveBeenCalled(); + await snap; + await s.stop(); + }); + + it("starts the livestream when the slot is granted", async () => { + const { mockWsClient, startLivestream } = makeWs(); + const lease = { release: jest.fn(), active: true }; + const acquireStreamSlot = jest.fn().mockReturnValue(lease); + const s = new StreamServer({ + port: testPort, + host: "127.0.0.1", + debug: true, + wsClient: mockWsClient as any, + serialNumber: "TEST123", + acquireStreamSlot, + }); + await s.start(); + + await expect(s.captureSnapshot(150)).rejects.toThrow(/timed out/); + + expect(acquireStreamSlot).toHaveBeenCalled(); + expect(startLivestream).toHaveBeenCalled(); + await s.stop(); + }); + }); + + describe("keyframe cache (getCachedKeyframe)", () => { + const makeServer = () => { + const mockWsClient = { + addEventListener: jest.fn().mockReturnValue(() => {}), + commands: { + device: jest.fn().mockReturnValue({ + startLivestream: jest.fn().mockResolvedValue({}), + stopLivestream: jest.fn().mockResolvedValue({}), + }), + }, + }; + const s = new StreamServer({ + port: testPort, + host: "127.0.0.1", + debug: true, + wsClient: mockWsClient as any, + serialNumber: "TEST123", + }); + return { s, mockWsClient }; + }; + + it("returns null before any keyframe is seen", async () => { + const { s } = makeServer(); + await s.start(); + expect(s.getCachedKeyframe(60000)).toBeNull(); + await s.stop(); + }); + + it("caches a keyframe that flows through with no snapshot pending", async () => { + const { s, mockWsClient } = makeServer(); + await s.start(); + const eventHandler = mockWsClient.addEventListener.mock.calls[0][1]; + + // No captureSnapshot() in flight — keyframe arrives because some other + // consumer (live view, HKSV, motion recording) woke the camera. + eventHandler({ + serialNumber: "TEST123", + buffer: { data: createTestH264Data() }, + metadata: { + videoCodec: "h264", + videoFPS: 30, + videoWidth: 1920, + videoHeight: 1080, + }, + }); + + const cached = s.getCachedKeyframe(60000); + expect(cached).not.toBeNull(); + expect(cached!.codec).toBe("H264"); + expect(cached!.data.length).toBeGreaterThan(0); + expect(cached!.ageMs).toBeGreaterThanOrEqual(0); + await s.stop(); + }); + + it("records the codec for an H.265 keyframe", async () => { + const { s, mockWsClient } = makeServer(); + await s.start(); + const eventHandler = mockWsClient.addEventListener.mock.calls[0][1]; + + eventHandler({ + serialNumber: "TEST123", + buffer: { data: createTestHevcData() }, + metadata: { + videoCodec: "h265", + videoFPS: 15, + videoWidth: 1280, + videoHeight: 720, + }, + }); + + const cached = s.getCachedKeyframe(60000); + expect(cached).not.toBeNull(); + expect(cached!.codec).toBe("H265"); + await s.stop(); + }); + + it("setCachedKeyframe seeds the cache (restore-after-reload)", async () => { + const { s } = makeServer(); + await s.start(); + expect(s.getCachedKeyframe(60000)).toBeNull(); + + const restored = Buffer.from([0x00, 0x00, 0x00, 0x01, 0x40, 0x01]); + s.setCachedKeyframe(restored, "H265"); + + const cached = s.getCachedKeyframe(60000); + expect(cached).not.toBeNull(); + expect(cached!.codec).toBe("H265"); + expect(cached!.data).toEqual(restored); + await s.stop(); + }); + + it("setCachedKeyframe does NOT overwrite a live keyframe", async () => { + const { s, mockWsClient } = makeServer(); + await s.start(); + const eventHandler = mockWsClient.addEventListener.mock.calls[0][1]; + eventHandler({ + serialNumber: "TEST123", + buffer: { data: createTestH264Data() }, + metadata: { + videoCodec: "h264", + videoFPS: 30, + videoWidth: 1920, + videoHeight: 1080, + }, + }); + s.setCachedKeyframe(Buffer.from([0xde, 0xad]), "H265"); + expect(s.getCachedKeyframe(60000)!.codec).toBe("H264"); + await s.stop(); + }); + + it("returns null when the cached keyframe is older than maxAgeMs", async () => { + const { s, mockWsClient } = makeServer(); + await s.start(); + const eventHandler = mockWsClient.addEventListener.mock.calls[0][1]; + + eventHandler({ + serialNumber: "TEST123", + buffer: { data: createTestH264Data() }, + metadata: { + videoCodec: "h264", + videoFPS: 30, + videoWidth: 1920, + videoHeight: 1080, + }, + }); + + await wait(50); + // Freshness window shorter than the elapsed time → treated as stale. + expect(s.getCachedKeyframe(10)).toBeNull(); + // But still available within a generous window. + expect(s.getCachedKeyframe(60000)).not.toBeNull(); + await s.stop(); + }); + }); + + describe("livestream active/inactive events (station registry signals)", () => { + const makeServer = () => { + const mockWsClient = { + addEventListener: jest.fn().mockReturnValue(() => {}), + commands: { + device: jest.fn().mockReturnValue({ + startLivestream: jest.fn().mockResolvedValue({}), + stopLivestream: jest.fn().mockResolvedValue({}), + }), + }, + }; + const s = new StreamServer({ + port: testPort, + host: "127.0.0.1", + debug: true, + wsClient: mockWsClient as any, + serialNumber: "TEST123", + }); + return { s, mockWsClient }; + }; + + it("emits livestreamActive with the serial when video first flows", async () => { + const { s, mockWsClient } = makeServer(); + await s.start(); + const eventHandler = mockWsClient.addEventListener.mock.calls[0][1]; + + const active = jest.fn(); + s.on("livestreamActive", active); + + eventHandler({ + serialNumber: "TEST123", + buffer: { data: createTestH264Data() }, + metadata: { + videoCodec: "h264", + videoFPS: 30, + videoWidth: 1920, + videoHeight: 1080, + }, + }); + + expect(active).toHaveBeenCalledTimes(1); + expect(active).toHaveBeenCalledWith({ serialNumber: "TEST123" }); + await s.stop(); + }); + + it("emits livestreamInactive on stop, and only once per transition", async () => { + const { s, mockWsClient } = makeServer(); + await s.start(); + const eventHandler = mockWsClient.addEventListener.mock.calls[0][1]; + + const active = jest.fn(); + const inactive = jest.fn(); + s.on("livestreamActive", active); + s.on("livestreamInactive", inactive); + + // Two video events — active should fire only once (transition). + for (let i = 0; i < 2; i++) { + eventHandler({ + serialNumber: "TEST123", + buffer: { data: createTestH264Data() }, + metadata: { + videoCodec: "h264", + videoFPS: 30, + videoWidth: 1920, + videoHeight: 1080, + }, + }); + } + expect(active).toHaveBeenCalledTimes(1); + + await s.stop(); + expect(inactive).toHaveBeenCalledWith({ serialNumber: "TEST123" }); + }); + }); + describe("getMuxedPort", () => { it("returns undefined before server starts", () => { expect(server.getMuxedPort()).toBeUndefined(); @@ -823,10 +1222,38 @@ describe("StreamServer", () => { describe("audio event handler", () => { let audioCallback: (event: any) => void; + let videoCallback: (event: any) => void; + + // Seed video metadata so handleMuxedClient's waitForVideoMetadata + // resolves quickly. Without this it would block for 15s waiting for + // the first video frame, and tests that rely on a muxer being attached + // would time out. + const seedVideoMetadata = () => { + // These tests exercise audio, so mark the device as audio-capable to + // skip the muxer's audio-detection wait and create it immediately + // (in "both" mode), matching the assumption these tests were written under. + (server as any).deliversAudio = true; + const startCode = Buffer.from([0x00, 0x00, 0x00, 0x01]); + // Minimal H.264 SPS NAL — content irrelevant for metadata capture. + const nal = Buffer.from([0x67, 0x42, 0x00, 0x1e]); + videoCallback({ + serialNumber: "TEST_DEVICE_123", + buffer: { data: Buffer.concat([startCode, nal]) }, + metadata: { + videoCodec: "H264", + videoFPS: 30, + videoWidth: 1280, + videoHeight: 720, + }, + }); + }; beforeEach(async () => { await server.start(); - // Audio listener is registered as the second addEventListener call + const videoCall = mockWsClient.addEventListener.mock.calls.find( + (call: any[]) => call[0] === "livestream video data", + ); + videoCallback = videoCall[1]; const audioCall = mockWsClient.addEventListener.mock.calls.find( (call: any[]) => call[0] === "livestream audio data", ); @@ -875,6 +1302,9 @@ describe("StreamServer", () => { host: "127.0.0.1", }); await new Promise((resolve) => socket.on("connect", resolve)); + // Seed video metadata so handleMuxedClient's metadata-wait resolves + // and the JMuxer gets constructed. + seedVideoMetadata(); await wait(50); expect((server as any).muxerStreams.size).toBe(1); @@ -902,6 +1332,29 @@ describe("StreamServer", () => { socket.destroy(); }); + it("only an ADTS frame marks the camera as audio-capable (non-ADTS stays video-only)", () => { + // A camera that emits audio events but no usable ADTS frame must NOT be + // flagged as delivering audio — otherwise the muxer picks `both` mode and + // hangs forever waiting for an audio sample that never arrives, and the + // live view stays black. Such a camera must be muxed video-only. + expect((server as any).deliversAudio).toBeUndefined(); + + audioCallback({ + serialNumber: "TEST_DEVICE_123", + buffer: { data: Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]) }, + metadata: { audioCodec: "AAC" }, + }); + expect((server as any).deliversAudio).toBeUndefined(); + + // A real ADTS frame flips it on so audio-capable cameras use `both`. + audioCallback({ + serialNumber: "TEST_DEVICE_123", + buffer: { data: Buffer.from([0xff, 0xf1, 0x50, 0x80, 0x00, 0x1f, 0xfc]) }, + metadata: { audioCodec: "AAC" }, + }); + expect((server as any).deliversAudio).toBe(true); + }); + it("feeds valid ADTS frames to all muxers", async () => { // Connect a muxed client const muxedPort = server.getMuxedPort()!; @@ -910,6 +1363,7 @@ describe("StreamServer", () => { host: "127.0.0.1", }); await new Promise((resolve) => socket.on("connect", resolve)); + seedVideoMetadata(); await wait(50); expect((server as any).muxerStreams.size).toBe(1); @@ -936,6 +1390,34 @@ describe("StreamServer", () => { }); describe("handleMuxedClient", () => { + // JMuxer is only constructed after the first video event arrives (so + // the muxer can be created with the correct codec). Helper to seed + // that metadata and unblock the in-flight `waitForVideoMetadata`. + const seedVideoMetadata = (s: StreamServer, codec: string = "H264") => { + // Default these tests to audio-capable so the muxer is built + // immediately (mode "both"), as they were written before audio-aware + // mode existed. The video-only path has its own dedicated tests. + (s as any).deliversAudio = true; + const videoCall = ( + (s as any).options.wsClient as any + ).addEventListener.mock.calls.find( + (call: any[]) => call[0] === "livestream video data", + ); + const videoCallback = videoCall[1]; + const startCode = Buffer.from([0x00, 0x00, 0x00, 0x01]); + const nal = Buffer.from([0x67, 0x42, 0x00, 0x1e]); + videoCallback({ + serialNumber: "TEST_DEVICE_123", + buffer: { data: Buffer.concat([startCode, nal]) }, + metadata: { + videoCodec: codec, + videoFPS: 30, + videoWidth: 1280, + videoHeight: 720, + }, + }); + }; + it("adds muxer to map on connection", async () => { await server.start(); @@ -947,9 +1429,14 @@ describe("StreamServer", () => { host: "127.0.0.1", }); await new Promise((resolve) => socket.on("connect", resolve)); + // Connection alone only registers the socket as pending; the muxer + // is built after the first video event delivers metadata. Seed the + // metadata, then wait for handleMuxedClient to construct the muxer. + seedVideoMetadata(server); await wait(50); expect((server as any).muxerStreams.size).toBe(1); + expect((server as any).pendingMuxerSockets.size).toBe(0); socket.destroy(); }); @@ -963,6 +1450,7 @@ describe("StreamServer", () => { host: "127.0.0.1", }); await new Promise((resolve) => socket.on("connect", resolve)); + seedVideoMetadata(server); await wait(50); expect((server as any).muxerStreams.size).toBe(1); @@ -978,6 +1466,154 @@ describe("StreamServer", () => { expect(muxerInstance.destroy).toHaveBeenCalled(); }); + it("constructs JMuxer with videoCodec=H264 for H.264 streams", async () => { + await server.start(); + + const muxedPort = server.getMuxedPort()!; + const socket = net.createConnection({ + port: muxedPort, + host: "127.0.0.1", + }); + await new Promise((resolve) => socket.on("connect", resolve)); + seedVideoMetadata(server, "H264"); + await wait(50); + + const JMuxerMock = require("jmuxer").default; + const lastCall = + JMuxerMock.mock.calls[JMuxerMock.mock.calls.length - 1][0]; + expect(lastCall.videoCodec).toBe("H264"); + + socket.destroy(); + }); + + it("constructs JMuxer with videoCodec=H265 for H.265 streams", async () => { + await server.start(); + + const muxedPort = server.getMuxedPort()!; + const socket = net.createConnection({ + port: muxedPort, + host: "127.0.0.1", + }); + await new Promise((resolve) => socket.on("connect", resolve)); + seedVideoMetadata(server, "H265"); + await wait(50); + + const JMuxerMock = require("jmuxer").default; + const lastCall = + JMuxerMock.mock.calls[JMuxerMock.mock.calls.length - 1][0]; + expect(lastCall.videoCodec).toBe("H265"); + + socket.destroy(); + }); + + it("rebuilds a stalled 'both' muxer as video-only so video still flows", async () => { + await server.start(); + // Make the fallback fire fast; the mocked muxer never emits any fMP4. + (server as any).BOTH_TO_VIDEO_FALLBACK_MS = 80; + + const muxedPort = server.getMuxedPort()!; + const socket = net.createConnection({ + port: muxedPort, + host: "127.0.0.1", + }); + await new Promise((resolve) => socket.on("connect", resolve)); + // seedVideoMetadata sets deliversAudio=true → muxer built in "both" mode. + seedVideoMetadata(server, "H265"); + await wait(50); + + const JMuxerMock = require("jmuxer").default; + const callsBefore = JMuxerMock.mock.calls.length; + expect(JMuxerMock.mock.calls[callsBefore - 1][0].mode).toBe("both"); + + // No fMP4 within the window → muxer is rebuilt video-only, same client. + await wait(140); + + expect(JMuxerMock.mock.calls.length).toBeGreaterThan(callsBefore); + expect( + JMuxerMock.mock.calls[JMuxerMock.mock.calls.length - 1][0].mode, + ).toBe("video"); + expect((server as any).muxerStreams.size).toBe(1); + + socket.destroy(); + }); + + it("muxes in 'both' mode when the device delivers audio", async () => { + await server.start(); + const socket = net.createConnection({ + port: server.getMuxedPort()!, + host: "127.0.0.1", + }); + await new Promise((resolve) => socket.on("connect", resolve)); + seedVideoMetadata(server, "H265"); // helper marks deliversAudio = true + await wait(50); + + const JMuxerMock = require("jmuxer").default; + const lastCall = + JMuxerMock.mock.calls[JMuxerMock.mock.calls.length - 1][0]; + expect(lastCall.mode).toBe("both"); + socket.destroy(); + }); + + it("muxes in 'video' mode for a video-only (mic-off) camera", async () => { + await server.start(); + const socket = net.createConnection({ + port: server.getMuxedPort()!, + host: "127.0.0.1", + }); + await new Promise((resolve) => socket.on("connect", resolve)); + + // Known video-only: skip the audio-detection wait. (Without this the + // muxer would block ~2.5s waiting for an audio frame that never comes.) + (server as any).deliversAudio = false; + + // Seed video metadata directly — the helper would force audio=true. + const videoCallback = ( + (server as any).options.wsClient as any + ).addEventListener.mock.calls.find( + (call: any[]) => call[0] === "livestream video data", + )[1]; + videoCallback({ + serialNumber: "TEST_DEVICE_123", + buffer: { + data: Buffer.from([0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1e]), + }, + metadata: { + videoCodec: "H265", + videoFPS: 15, + videoWidth: 1920, + videoHeight: 1080, + }, + }); + await wait(50); + + const JMuxerMock = require("jmuxer").default; + const lastCall = + JMuxerMock.mock.calls[JMuxerMock.mock.calls.length - 1][0]; + expect(lastCall.mode).toBe("video"); + socket.destroy(); + }); + + it("pending muxer client counts as a consumer (triggers livestream start)", async () => { + await server.start(); + + const muxedPort = server.getMuxedPort()!; + const socket = net.createConnection({ + port: muxedPort, + host: "127.0.0.1", + }); + await new Promise((resolve) => socket.on("connect", resolve)); + // Don't seed metadata — leave the socket pending. The livestream + // state machine should still consider it a consumer and ask the WS + // client to start the livestream. + await wait(20); + + expect( + mockWsClient.commands.device().startLivestream, + ).toHaveBeenCalled(); + + socket.destroy(); + }); + it("JMuxer duplex data is written to socket", async () => { await server.start(); @@ -991,6 +1627,7 @@ describe("StreamServer", () => { socket.on("data", (chunk) => receivedChunks.push(chunk as Buffer)); await new Promise((resolve) => socket.on("connect", resolve)); + seedVideoMetadata(server); await wait(50); const JMuxerMock = require("jmuxer").default; From 144cb84f690830256b1042cf39cac1a7f6cfb89c Mon Sep 17 00:00:00 2001 From: Josh Anon Date: Sun, 31 May 2026 13:13:42 -0700 Subject: [PATCH 2/3] feat: in-plugin H.265 -> H.264 transcoding for plug-and-play HomeKit/WebRTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrypted's FFmpegInput can't request H.264 output — consumers (HomeKit, the Scrypted-UI WebRTC preview) do `-c:v copy` unless the user manually enables a per-camera "Transcoding Debug Mode". To make H.264 work out of the box (and fix the black H.265-over-WebRTC live view), the plugin now emits real H.264 itself. - h264-transcode-server.ts: a per-device local TCP relay. Each client connection spawns one ffmpeg that reads the stream server's muxed fMP4 (H.265) port and pipes fragmented-MP4 H.264 (libx264 ultrafast/zerolatency, audio copied through) to that socket. One ffmpeg per client; stop() destroys live sockets so the listener closes promptly. The relay's ffmpeg becoming a muxed-port reader is what wakes/idle-stops the livestream, so the cold-start and coordinator lifecycle is preserved. - stream-service.ts: when the toggle is on AND the source is H.265, return a MediaObject that reads the relay (advertised as h264); native H.264 passes through untouched, so a camera that sometimes sends H.264 is never needlessly re-encoded. getVideoStreamOptions advertises h264 to match. - eufy-device.ts: per-camera "Transcode to H.264" setting (Streaming group), default ON for cameras whose last-detected codec is H.265. Trade-off: one shared software encode per active stream (no per-consumer bitrate adaptation), CPU cost on the host; the coordinator caps concurrency to ~one live stream per HomeBase. Toggle off per-camera if the host runs hot. Tests: relay lifecycle/spawn/teardown + stream-service transcode branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../eufy-security-scrypted/src/eufy-device.ts | 39 +++ .../src/services/device/stream-service.ts | 130 ++++++++- .../src/utils/h264-transcode-server.ts | 252 ++++++++++++++++++ .../unit/services/stream-service.test.ts | 81 ++++++ .../unit/utils/h264-transcode-server.test.ts | 173 ++++++++++++ 5 files changed, 672 insertions(+), 3 deletions(-) create mode 100644 packages/eufy-security-scrypted/src/utils/h264-transcode-server.ts create mode 100644 packages/eufy-security-scrypted/tests/unit/utils/h264-transcode-server.test.ts diff --git a/packages/eufy-security-scrypted/src/eufy-device.ts b/packages/eufy-security-scrypted/src/eufy-device.ts index e51e282..860cb14 100644 --- a/packages/eufy-security-scrypted/src/eufy-device.ts +++ b/packages/eufy-security-scrypted/src/eufy-device.ts @@ -105,6 +105,7 @@ import { } from "./utils/thumbnail-refresh"; const THUMBNAIL_REFRESH_SETTING_KEY = "thumbnailRefreshInterval"; +const TRANSCODE_H264_SETTING_KEY = "transcodeToH264"; import { VideoClipsService } from "./services/video"; import { PtzControlService, LightControlService } from "./services/control"; @@ -312,6 +313,7 @@ export class EufyDevice this.serialNumber, this.streamServer, this.logger, + () => this.shouldTranscodeToH264(), ); this.ptzControlService = new PtzControlService( deviceApi, @@ -477,9 +479,39 @@ export class EufyDevice group: "Streaming", }); + // Per-camera in-plugin H.265 → H.264 transcode (HomeKit/WebRTC compat). + settings.push({ + key: TRANSCODE_H264_SETTING_KEY, + title: "Transcode to H.264", + description: + "Re-encode this camera's H.265/HEVC video to H.264 inside the plugin " + + "so HomeKit live view and the Scrypted browser preview (WebRTC) work " + + "without enabling Scrypted's per-camera \"Transcoding Debug Mode\". " + + "Only engages while the stream is actually H.265 — native H.264 is " + + "passed through untouched. Costs CPU on the server (one software " + + "encode per active stream). Default: on for H.265 cameras.", + type: "boolean", + value: this.shouldTranscodeToH264(), + group: "Streaming", + }); + return settings; } + /** + * Whether to emit H.264 (transcoded) to Scrypted for this camera. Honors the + * explicit per-camera toggle; when unset, defaults ON for cameras whose + * last-detected codec is H.265. The stream path additionally gates on the + * live codec actually being H.265, so a camera that sometimes sends native + * H.264 is never needlessly re-encoded. + */ + private shouldTranscodeToH264(): boolean { + const raw = this.storage.getItem(TRANSCODE_H264_SETTING_KEY); + if (raw === "true") return true; + if (raw === "false") return false; + return this.storage.getItem("lastDetectedVideoCodec") === "H265"; + } + /** * Update device settings using DeviceSettingsService * Delegates to the settings service for property updates and custom settings @@ -492,6 +524,13 @@ export class EufyDevice return; } + // Per-camera H.264 transcode toggle — stored locally, not a device prop. + if (key === TRANSCODE_H264_SETTING_KEY) { + this.storage.setItem(key, String(value)); + this.logger.info(`🎞️ Transcode to H.264 set to: ${value}`); + return; + } + // Callback to handle successful property updates const onSuccess = (settingKey: string, settingValue: SettingValue) => { // Update local properties if it's a device property diff --git a/packages/eufy-security-scrypted/src/services/device/stream-service.ts b/packages/eufy-security-scrypted/src/services/device/stream-service.ts index ab0ab4e..60373df 100644 --- a/packages/eufy-security-scrypted/src/services/device/stream-service.ts +++ b/packages/eufy-security-scrypted/src/services/device/stream-service.ts @@ -16,6 +16,7 @@ import { import sdk from "@scrypted/sdk"; import { VideoQuality } from "@caplaz/eufy-security-client"; import { FFmpegUtils } from "../../utils/ffmpeg-utils"; +import { H264TranscodeServer } from "../../utils/h264-transcode-server"; import { Logger, ILogObj } from "tslog"; import { IStreamServer } from "./types"; @@ -47,13 +48,34 @@ export interface StreamConfig { */ export class StreamService { private streamServerStarted = false; + private transcodeServer?: H264TranscodeServer; + private resolvedFfmpegPath?: string; + /** + * @param shouldTranscode - returns whether this camera should emit H.264 to + * Scrypted (the per-camera "Transcode to H.264" toggle). Transcoding only + * actually engages when this returns true AND the live source is H.265 — + * native H.264 is always passed through untouched. Defaults to disabled. + */ constructor( private serialNumber: string, private streamServer: IStreamServer, private logger: Logger, + private shouldTranscode: () => boolean = () => false, ) {} + /** + * True when we should hand Scrypted a transcoded H.264 stream: the toggle is + * on, the source is H.265, and the muxed fMP4 port (our transcode source) is + * available. Native H.264 sources never transcode. + */ + private transcodeEnabled(): boolean { + if (!this.shouldTranscode()) return false; + const eufyCodec = this.streamServer.getVideoMetadata()?.videoCodec ?? "H264"; + if (FFmpegUtils.toScryptedCodec(eufyCodec) !== "h265") return false; + return !!this.streamServer.getMuxedPort(); + } + /** * Get video dimensions based on quality setting * @@ -83,9 +105,14 @@ export class StreamService { */ getVideoStreamOptions(quality?: VideoQuality): ResponseMediaStreamOptions[] { const { width, height } = this.getVideoDimensions(quality); - const codec = FFmpegUtils.toScryptedCodec( - this.streamServer.getVideoMetadata()?.videoCodec ?? "H264", - ); + // Advertise H.264 when we will transcode, so downstream consumers know the + // stream is plain H.264 (no Scrypted-side transcode needed); otherwise + // report the true source codec. + const codec = this.transcodeEnabled() + ? "h264" + : FFmpegUtils.toScryptedCodec( + this.streamServer.getVideoMetadata()?.videoCodec ?? "H264", + ); return [ { @@ -137,15 +164,112 @@ export class StreamService { "Creating MediaObject with fallback dimensions (metadata will be updated when stream starts)", ); + // H.265 source + transcode toggle on → hand Scrypted real H.264 from the + // in-plugin transcode relay so HomeKit / WebRTC work without Scrypted's + // per-camera Transcoding Debug Mode. + if (this.transcodeEnabled()) { + const transcodePort = await this.ensureTranscodeServer(); + if (transcodePort) { + this.logger.info( + `🎞️ Serving H.264 (transcoded from H.265) via relay port ${transcodePort}`, + ); + return await this.createTranscodedMediaObject( + transcodePort, + quality, + options, + ); + } + this.logger.warn( + "Transcode requested but relay unavailable — falling back to passthrough", + ); + } + return await this.createOptimizedMediaObject(port, quality, options); } + /** + * Lazily start the H.264 transcode relay and return its port. Resolves the + * Scrypted-bundled ffmpeg path once (falls back to "ffmpeg" on PATH). + */ + private async ensureTranscodeServer(): Promise { + if (!this.transcodeServer) { + if (this.resolvedFfmpegPath === undefined) { + try { + this.resolvedFfmpegPath = await sdk.mediaManager.getFFmpegPath(); + } catch { + this.resolvedFfmpegPath = "ffmpeg"; + } + } + this.transcodeServer = new H264TranscodeServer({ + serialNumber: this.serialNumber, + logger: this.logger, + getSourcePort: () => this.streamServer.getMuxedPort(), + ffmpegPath: this.resolvedFfmpegPath, + }); + } + if (!this.transcodeServer.isRunning()) { + await this.transcodeServer.start(); + } + return this.transcodeServer.getPort(); + } + + /** + * Build a MediaObject that reads plain H.264 fMP4 from the transcode relay. + */ + private async createTranscodedMediaObject( + transcodePort: number, + quality: VideoQuality | undefined, + options?: RequestMediaStreamOptions, + ): Promise { + const { width, height } = this.getVideoDimensions(quality); + + const inputArguments = [ + "-hide_banner", + "-loglevel", + "error", + "-fflags", + "+genpts+nobuffer", + "-analyzeduration", + "2000000", + "-probesize", + "1000000", + "-f", + "mp4", + "-i", + `tcp://127.0.0.1:${transcodePort}`, + ]; + + const ffmpegInput: FFmpegInput = { + url: undefined, + inputArguments, + mediaStreamOptions: { + id: options?.id || "main", + name: options?.name || "Eufy Camera Stream (H.264)", + container: "mp4", + video: { + ...options?.video, + // The relay emits real H.264, so this label is accurate and the + // downstream `-vcodec copy` produces a valid H.264 stream. + codec: "h264", + width, + height, + }, + audio: { codec: "aac" }, + }, + }; + + return await sdk.mediaManager.createFFmpegMediaObject(ffmpegInput); + } + /** * Stop the video stream * * @returns Promise that resolves when stream is stopped */ async stopStream(): Promise { + if (this.transcodeServer) { + await this.transcodeServer.stop(); + } if (this.streamServerStarted) { this.logger.info("Stopping stream server"); await this.streamServer.stop(); diff --git a/packages/eufy-security-scrypted/src/utils/h264-transcode-server.ts b/packages/eufy-security-scrypted/src/utils/h264-transcode-server.ts new file mode 100644 index 0000000..19784f2 --- /dev/null +++ b/packages/eufy-security-scrypted/src/utils/h264-transcode-server.ts @@ -0,0 +1,252 @@ +/** + * H.264 transcode relay server + * + * A Eufy HomeBase delivers H.265/HEVC from many cameras. HomeKit live view and + * the Scrypted browser (WebRTC) only speak H.264, and Scrypted's consumers do + * `-c:v copy` unless the user manually flips a per-camera "Transcoding Debug + * Mode". To make H.264 work out of the box, the plugin emits real H.264 bytes + * itself: this relay sits in front of the stream server's muxed fMP4 (H.265) + * port and re-encodes to H.264 on demand. + * + * It listens on a local TCP port and, for EACH client that connects (Scrypted's + * per-consumer ffmpeg / the Rebroadcast prebuffer), spawns a dedicated ffmpeg + * that reads the muxed fMP4 source, re-encodes video to H.264 (libx264) while + * copying audio, and pipes fragmented MP4 to that client socket. + * + * One ffmpeg per connection keeps the lifecycle trivial and gives every client + * its own clean `moov` init segment — the muxed server already fans out raw + * fMP4 to multiple readers, so a fresh per-client encode just attaches as + * another reader. A client connecting here is also what wakes the livestream + * (the spawned ffmpeg becomes a muxed-port reader), and its disconnect is what + * lets the stream idle-stop — so the existing cold-start / coordinator + * lifecycle is preserved unchanged. + * + * @module utils/h264-transcode-server + */ + +import * as net from "net"; +import { spawn as defaultSpawn } from "child_process"; +import { Logger, ILogObj } from "tslog"; + +/** Minimal shape of a spawned child we rely on (injectable for tests). */ +export interface SpawnedChild { + stdout: NodeJS.ReadableStream | null; + stderr: NodeJS.ReadableStream | null; + kill(signal?: NodeJS.Signals | number): boolean; + on(event: "exit", listener: (code: number | null) => void): unknown; + on(event: "error", listener: (err: Error) => void): unknown; +} + +export type SpawnFn = (command: string, args: string[]) => SpawnedChild; + +export interface H264TranscodeServerOptions { + serialNumber: string; + logger: Logger; + /** + * Returns the muxed fMP4 (H.265) source port to read from, or undefined if + * the stream server is not currently listening. Read fresh per connection so + * a restarted stream server (new port) is picked up automatically. + */ + getSourcePort: () => number | undefined; + /** Path to the ffmpeg binary. Defaults to "ffmpeg" on PATH. */ + ffmpegPath?: string; + /** Injectable spawn (tests). Defaults to child_process.spawn. */ + spawnFn?: SpawnFn; +} + +/** + * Build the ffmpeg argument list that re-encodes the muxed fMP4 (H.265) source + * on `sourcePort` to fragmented-MP4 H.264 on stdout. Exported for testing. + * + * - libx264 ultrafast/zerolatency: portable software encode, low added latency. + * - High@4.1, yuv420p: broadly compatible with HomeKit clients and browsers. + * - audio copied through (AAC); the audio map is optional so mic-off cameras + * (video-only muxer) don't fail the encode. + * - fragmented MP4 (`frag_keyframe+empty_moov+default_base_moof`) so the + * downstream consumer can start mid-stream, same contract as the muxed port. + */ +export function buildTranscodeArgs(sourcePort: number): string[] { + return [ + "-hide_banner", + "-loglevel", + "error", + "-fflags", + "+genpts+nobuffer", + "-analyzeduration", + "2000000", + "-probesize", + "1000000", + "-f", + "mp4", + "-i", + `tcp://127.0.0.1:${sourcePort}`, + // Video: re-encode H.265 -> H.264. Audio: pass AAC through if present. + "-map", + "0:v:0", + "-map", + "0:a:0?", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-tune", + "zerolatency", + "-pix_fmt", + "yuv420p", + "-profile:v", + "high", + "-level", + "4.1", + "-g", + "30", + "-keyint_min", + "15", + "-sc_threshold", + "0", + "-b:v", + "2000k", + "-maxrate", + "2500k", + "-bufsize", + "4000k", + "-c:a", + "copy", + "-f", + "mp4", + "-movflags", + "+frag_keyframe+empty_moov+default_base_moof", + "pipe:1", + ]; +} + +export class H264TranscodeServer { + private server?: net.Server; + private readonly children = new Set(); + private readonly sockets = new Set(); + private readonly ffmpegPath: string; + private readonly spawnFn: SpawnFn; + + constructor(private readonly options: H264TranscodeServerOptions) { + this.ffmpegPath = options.ffmpegPath ?? "ffmpeg"; + this.spawnFn = options.spawnFn ?? (defaultSpawn as unknown as SpawnFn); + } + + /** Start listening on a free localhost port. Idempotent. */ + async start(): Promise { + if (this.server) return; + await new Promise((resolve, reject) => { + const server = net.createServer((socket) => this.handleClient(socket)); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + this.server = server; + this.options.logger.info( + `🎞️ H.264 transcode relay listening on port ${this.getPort()}`, + ); + resolve(); + }); + }); + } + + /** The TCP port the relay is listening on, or undefined if not started. */ + getPort(): number | undefined { + const address = this.server?.address(); + return address && typeof address === "object" ? address.port : undefined; + } + + isRunning(): boolean { + return !!this.server; + } + + private handleClient(socket: net.Socket): void { + const sourcePort = this.options.getSourcePort(); + if (!sourcePort) { + // Nothing to transcode from yet — close so the consumer retries. + this.options.logger.warn( + "H.264 transcode: no muxed source port available, closing client", + ); + socket.destroy(); + return; + } + + this.sockets.add(socket); + + const args = buildTranscodeArgs(sourcePort); + this.options.logger.info( + `🎞️ H.264 transcode: encoding muxed port ${sourcePort} for a new client`, + ); + + let child: SpawnedChild; + try { + child = this.spawnFn(this.ffmpegPath, args); + } catch (e) { + this.options.logger.error(`H.264 transcode: failed to spawn ffmpeg: ${e}`); + socket.destroy(); + return; + } + this.children.add(child); + + let cleanedUp = false; + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + this.children.delete(child); + this.sockets.delete(socket); + try { + child.kill("SIGKILL"); + } catch { + // already gone + } + if (!socket.destroyed) socket.destroy(); + }; + + // ffmpeg stdout -> client. Pipe (don't end-on-error) so cleanup controls teardown. + child.stdout?.on("data", (chunk: Buffer) => { + const ok = socket.write(chunk); + if (!ok) { + // Backpressure: nothing fancy — ffmpeg's stdout buffers briefly. + } + }); + + child.stderr?.on("data", (chunk: Buffer) => { + const msg = chunk.toString().trim(); + if (msg) this.options.logger.debug(`H.264 transcode ffmpeg: ${msg}`); + }); + + child.on("exit", (code) => { + this.options.logger.debug(`H.264 transcode ffmpeg exited (code ${code})`); + cleanup(); + }); + child.on("error", (err) => { + this.options.logger.error(`H.264 transcode ffmpeg error: ${err}`); + cleanup(); + }); + + socket.on("close", cleanup); + socket.on("error", cleanup); + } + + /** Stop the relay: close the listener and kill every active encode. */ + async stop(): Promise { + for (const child of this.children) { + try { + child.kill("SIGKILL"); + } catch { + // already gone + } + } + this.children.clear(); + + // Destroy live client sockets so server.close() can resolve immediately + // (close() waits for existing connections to end on its own otherwise). + for (const socket of this.sockets) { + if (!socket.destroyed) socket.destroy(); + } + this.sockets.clear(); + + const server = this.server; + this.server = undefined; + if (!server) return; + await new Promise((resolve) => server.close(() => resolve())); + } +} diff --git a/packages/eufy-security-scrypted/tests/unit/services/stream-service.test.ts b/packages/eufy-security-scrypted/tests/unit/services/stream-service.test.ts index c0bbfd8..ef3472b 100644 --- a/packages/eufy-security-scrypted/tests/unit/services/stream-service.test.ts +++ b/packages/eufy-security-scrypted/tests/unit/services/stream-service.test.ts @@ -15,6 +15,7 @@ jest.mock("@scrypted/sdk", () => ({ default: { mediaManager: { createFFmpegMediaObject: jest.fn(), + getFFmpegPath: jest.fn().mockResolvedValue("ffmpeg"), }, }, })); @@ -375,4 +376,84 @@ describe("StreamService", () => { expect(call.mediaStreamOptions.audio).toEqual({ codec: "aac" }); }); }); + + describe("H.264 transcode toggle", () => { + const h265Server = () => { + mockStreamServer.getVideoMetadata = jest.fn().mockReturnValue({ + videoCodec: "H265", + videoWidth: 1920, + videoHeight: 1080, + videoFPS: 15, + }); + mockStreamServer.getMuxedPort = jest.fn().mockReturnValue(55555); + }; + + it("emits H.264 from the relay (not the muxed port) when toggled on for an H.265 source", async () => { + h265Server(); + const svc = new StreamService( + serialNumber, + mockStreamServer, + mockLogger, + () => true, + ); + + await svc.getVideoStream(VideoQuality.HIGH); + const call = (sdk.mediaManager.createFFmpegMediaObject as jest.Mock).mock + .calls[0][0]; + + // Advertised as real H.264, named as the transcoded stream. + expect(call.mediaStreamOptions.video.codec).toBe("h264"); + expect(call.mediaStreamOptions.name).toContain("H.264"); + // Reads from the relay's dynamic port, NOT the muxed source port (55555). + const inputUrl = call.inputArguments[call.inputArguments.length - 1]; + expect(inputUrl).toMatch(/^tcp:\/\/127\.0\.0\.1:\d+$/); + expect(inputUrl).not.toContain("55555"); + + await svc.dispose(); + }); + + it("does NOT transcode a native H.264 source even when the toggle is on", async () => { + mockStreamServer.getVideoMetadata = jest + .fn() + .mockReturnValue({ videoCodec: "H264" }); + mockStreamServer.getMuxedPort = jest.fn().mockReturnValue(55555); + const svc = new StreamService( + serialNumber, + mockStreamServer, + mockLogger, + () => true, + ); + + await svc.getVideoStream(VideoQuality.HIGH); + const call = (sdk.mediaManager.createFFmpegMediaObject as jest.Mock).mock + .calls[0][0]; + + // Passthrough muxed path: reads the muxed port directly, default name. + expect(call.inputArguments).toContain("tcp://127.0.0.1:55555"); + expect(call.mediaStreamOptions.name).toBe("Eufy Camera Stream"); + + await svc.dispose(); + }); + + it("getVideoStreamOptions advertises h264 when transcoding an H.265 source", () => { + h265Server(); + const svc = new StreamService( + serialNumber, + mockStreamServer, + mockLogger, + () => true, + ); + expect(svc.getVideoStreamOptions(VideoQuality.HIGH)[0].video?.codec).toBe( + "h264", + ); + }); + + it("getVideoStreamOptions reports the true h265 codec when the toggle is off", () => { + h265Server(); + // default service has transcode disabled + expect( + service.getVideoStreamOptions(VideoQuality.HIGH)[0].video?.codec, + ).toBe("h265"); + }); + }); }); diff --git a/packages/eufy-security-scrypted/tests/unit/utils/h264-transcode-server.test.ts b/packages/eufy-security-scrypted/tests/unit/utils/h264-transcode-server.test.ts new file mode 100644 index 0000000..8b65e45 --- /dev/null +++ b/packages/eufy-security-scrypted/tests/unit/utils/h264-transcode-server.test.ts @@ -0,0 +1,173 @@ +/** + * H.264 transcode relay server tests + */ + +import * as net from "net"; +import { EventEmitter } from "events"; +import { PassThrough } from "stream"; +import { + H264TranscodeServer, + buildTranscodeArgs, + SpawnFn, +} from "../../../src/utils/h264-transcode-server"; + +const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + silly: jest.fn(), + trace: jest.fn(), +} as any; + +/** A controllable fake child process matching the SpawnedChild shape. */ +function makeFakeChild() { + const emitter = new EventEmitter(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const kill = jest.fn(() => true); + return { + stdout, + stderr, + kill, + on: (ev: string, cb: (...a: any[]) => void) => emitter.on(ev, cb), + emit: (ev: string, arg?: any) => emitter.emit(ev, arg), + }; +} + +function connect(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(port, "127.0.0.1"); + socket.once("connect", () => resolve(socket)); + socket.once("error", reject); + }); +} + +const tick = () => new Promise((r) => setTimeout(r, 20)); + +describe("buildTranscodeArgs", () => { + it("re-encodes to H.264 from the given muxed source port, copying audio", () => { + const args = buildTranscodeArgs(40123); + const joined = args.join(" "); + expect(joined).toContain("-i tcp://127.0.0.1:40123"); + expect(joined).toContain("-c:v libx264"); + expect(joined).toContain("-c:a copy"); + // Optional audio map so video-only (mic-off) cameras don't fail. + expect(args).toContain("0:a:0?"); + // Fragmented MP4 so the downstream consumer can start mid-stream. + expect(joined).toContain("frag_keyframe"); + expect(args[args.length - 1]).toBe("pipe:1"); + }); +}); + +describe("H264TranscodeServer", () => { + let servers: H264TranscodeServer[] = []; + let sockets: net.Socket[] = []; + + const make = (opts: { + getSourcePort: () => number | undefined; + spawnFn?: SpawnFn; + }) => { + const s = new H264TranscodeServer({ + serialNumber: "TEST", + logger: mockLogger, + ...opts, + }); + servers.push(s); + return s; + }; + + afterEach(async () => { + for (const s of sockets) s.destroy(); + sockets = []; + for (const s of servers) await s.stop(); + servers = []; + jest.clearAllMocks(); + }); + + it("listens on a free port after start and reports it", async () => { + const server = make({ getSourcePort: () => 50000 }); + expect(server.isRunning()).toBe(false); + await server.start(); + expect(server.isRunning()).toBe(true); + expect(typeof server.getPort()).toBe("number"); + }); + + it("spawns one ffmpeg per client and pipes its stdout to the socket", async () => { + const child = makeFakeChild(); + const spawnFn = jest.fn(() => child) as unknown as SpawnFn; + const server = make({ getSourcePort: () => 51111, spawnFn }); + await server.start(); + + const client = await connect(server.getPort()!); + sockets.push(client); + await tick(); + + expect(spawnFn).toHaveBeenCalledTimes(1); + const [, args] = (spawnFn as jest.Mock).mock.calls[0]; + expect(args.join(" ")).toContain("-i tcp://127.0.0.1:51111"); + + const received = new Promise((resolve) => + client.once("data", resolve), + ); + child.stdout.write(Buffer.from("h264-bytes")); + expect((await received).toString()).toBe("h264-bytes"); + }); + + it("refuses (closes) a client when no muxed source port is available", async () => { + const spawnFn = jest.fn() as unknown as SpawnFn; + const server = make({ getSourcePort: () => undefined, spawnFn }); + await server.start(); + + const client = await connect(server.getPort()!); + sockets.push(client); + const closed = new Promise((resolve) => + client.once("close", () => resolve()), + ); + await tick(); + + expect(spawnFn).not.toHaveBeenCalled(); + await closed; // socket was destroyed by the server + }); + + it("kills the ffmpeg when the client disconnects", async () => { + const child = makeFakeChild(); + const spawnFn = jest.fn(() => child) as unknown as SpawnFn; + const server = make({ getSourcePort: () => 52222, spawnFn }); + await server.start(); + + const client = await connect(server.getPort()!); + await tick(); + expect(spawnFn).toHaveBeenCalledTimes(1); + + client.end(); + client.destroy(); + await tick(); + expect(child.kill).toHaveBeenCalled(); + }); + + it("stop() kills active encodes and closes the listener", async () => { + const child = makeFakeChild(); + const spawnFn = jest.fn(() => child) as unknown as SpawnFn; + const server = make({ getSourcePort: () => 53333, spawnFn }); + await server.start(); + + const client = await connect(server.getPort()!); + sockets.push(client); + await tick(); + + await server.stop(); + expect(child.kill).toHaveBeenCalled(); + expect(server.isRunning()).toBe(false); + expect(server.getPort()).toBeUndefined(); + }); + + it("start() is idempotent", async () => { + const server = make({ getSourcePort: () => 54444 }); + await server.start(); + const port = server.getPort(); + await server.start(); + expect(server.getPort()).toBe(port); + }); +}); From 03e1343c1154cc2b7eede2196e852e537fd7b9db Mon Sep 17 00:00:00 2001 From: Josh Anon Date: Sun, 31 May 2026 13:34:56 -0700 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20thermal=20governor=20=E2=80=94=20au?= =?UTF-8?q?to-throttle=20transcoding=20when=20the=20host=20CPU=20is=20hot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-plugin H.264 transcode is a software encode per active stream, which can heat a small host (Raspberry Pi) under concurrent streams. Add a process-wide thermal governor that samples the host CPU temperature (/sys/class/thermal/thermal_zone0/temp) every 10s and: - warns (log + Scrypted alert via log.a) at >=70C, - throttles at >=78C: new streams that would transcode fall back to H.265 passthrough (no encode) until the host cools, - uses hysteresis (clear critical <72C, clear warn <66C) so it doesn't flap, - is inert when the temperature source is unreadable (non-Pi / sandbox) — it can only ever make the transcode path more conservative, never break it. Existing encodes are left to finish (short live-view sessions) rather than killed mid-frame. StreamService gains an isThrottling getter; the provider starts the governor and routes alerts to the Scrypted UI. Tests: thresholds, hysteresis, unreadable-source inertness, alert transitions, and the singleton throttle flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../eufy-security-scrypted/src/eufy-device.ts | 2 + .../src/eufy-provider.ts | 9 + .../src/services/device/stream-service.ts | 27 ++- .../src/utils/thermal-governor.ts | 198 ++++++++++++++++++ .../tests/unit/utils/thermal-governor.test.ts | 129 ++++++++++++ 5 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 packages/eufy-security-scrypted/src/utils/thermal-governor.ts create mode 100644 packages/eufy-security-scrypted/tests/unit/utils/thermal-governor.test.ts diff --git a/packages/eufy-security-scrypted/src/eufy-device.ts b/packages/eufy-security-scrypted/src/eufy-device.ts index 860cb14..871385f 100644 --- a/packages/eufy-security-scrypted/src/eufy-device.ts +++ b/packages/eufy-security-scrypted/src/eufy-device.ts @@ -96,6 +96,7 @@ import { recycleSuppression, RECYCLE_SUPPRESS_MS, } from "./utils/recycle-guard"; +import { isTranscodeThermallyThrottled } from "./utils/thermal-governor"; import { shouldRefreshThumbnail, nextRefreshBackoffMs, @@ -314,6 +315,7 @@ export class EufyDevice this.streamServer, this.logger, () => this.shouldTranscodeToH264(), + () => isTranscodeThermallyThrottled(), ); this.ptzControlService = new PtzControlService( deviceApi, diff --git a/packages/eufy-security-scrypted/src/eufy-provider.ts b/packages/eufy-security-scrypted/src/eufy-provider.ts index 8a9c257..526e452 100644 --- a/packages/eufy-security-scrypted/src/eufy-provider.ts +++ b/packages/eufy-security-scrypted/src/eufy-provider.ts @@ -53,6 +53,7 @@ import { Logger, ILogObj, ILogObjMeta } from "tslog"; import { EufyStation } from "./eufy-station"; import { DeviceUtils } from "./utils/device-utils"; import { MemoryManager } from "./utils/memory-manager"; +import { startThermalGovernor } from "./utils/thermal-governor"; const { deviceManager } = sdk; @@ -162,6 +163,14 @@ export class EufySecurityProvider }, ); + // Watch host CPU temperature and auto-throttle H.264 transcoding when the + // host gets too hot (the encode is the main CPU load this plugin adds). + // Inert if the temperature source is unreadable (non-Pi / sandbox). + startThermalGovernor({ + logger: this.logger.getSubLogger({ name: "Thermal" }), + onAlert: (_level, _tempC, message) => this.log.a(message), + }); + this.logger.info("🚀 EufySecurityProvider initialized"); // Start connection automatically diff --git a/packages/eufy-security-scrypted/src/services/device/stream-service.ts b/packages/eufy-security-scrypted/src/services/device/stream-service.ts index 60373df..19895c3 100644 --- a/packages/eufy-security-scrypted/src/services/device/stream-service.ts +++ b/packages/eufy-security-scrypted/src/services/device/stream-service.ts @@ -56,26 +56,39 @@ export class StreamService { * Scrypted (the per-camera "Transcode to H.264" toggle). Transcoding only * actually engages when this returns true AND the live source is H.265 — * native H.264 is always passed through untouched. Defaults to disabled. + * @param isThrottling - returns whether transcoding is currently being + * suppressed to protect the host (CPU too hot). When true, a stream that + * would normally transcode falls back to H.265 passthrough. Defaults off. */ constructor( private serialNumber: string, private streamServer: IStreamServer, private logger: Logger, private shouldTranscode: () => boolean = () => false, + private isThrottling: () => boolean = () => false, ) {} /** - * True when we should hand Scrypted a transcoded H.264 stream: the toggle is - * on, the source is H.265, and the muxed fMP4 port (our transcode source) is - * available. Native H.264 sources never transcode. + * Whether this stream WOULD transcode (toggle on, source is H.265, muxed + * port available) — ignoring the thermal throttle. Used to log when the + * throttle is the only reason we're not transcoding. */ - private transcodeEnabled(): boolean { + private transcodeRequested(): boolean { if (!this.shouldTranscode()) return false; const eufyCodec = this.streamServer.getVideoMetadata()?.videoCodec ?? "H264"; if (FFmpegUtils.toScryptedCodec(eufyCodec) !== "h265") return false; return !!this.streamServer.getMuxedPort(); } + /** + * True when we should actually hand Scrypted a transcoded H.264 stream: + * transcoding is requested AND the host isn't thermally throttling. Native + * H.264 sources never transcode; a hot host falls back to H.265 passthrough. + */ + private transcodeEnabled(): boolean { + return this.transcodeRequested() && !this.isThrottling(); + } + /** * Get video dimensions based on quality setting * @@ -182,6 +195,12 @@ export class StreamService { this.logger.warn( "Transcode requested but relay unavailable — falling back to passthrough", ); + } else if (this.transcodeRequested() && this.isThrottling()) { + // Would transcode, but the host is too hot — serve H.265 as-is so we + // don't add encode load. Live view may degrade until the host cools. + this.logger.warn( + "🌡️ CPU hot — serving H.265 passthrough instead of transcoding to protect the host", + ); } return await this.createOptimizedMediaObject(port, quality, options); diff --git a/packages/eufy-security-scrypted/src/utils/thermal-governor.ts b/packages/eufy-security-scrypted/src/utils/thermal-governor.ts new file mode 100644 index 0000000..f9f0ec4 --- /dev/null +++ b/packages/eufy-security-scrypted/src/utils/thermal-governor.ts @@ -0,0 +1,198 @@ +/** + * Thermal governor + * + * The in-plugin H.264 transcode ([[in-plugin-h264-transcode]]) is a software + * (libx264) encode per active stream, which can heat a small host (the Pi) under + * concurrent streams. This governor periodically reads the host CPU temperature, + * warns when it gets hot, and auto-throttles new transcodes when it gets + * critically hot so the host can't cook itself. + * + * Process-wide singleton (one host, one temperature). The transcode path checks + * `isTranscodeThermallyThrottled()`; when true, new streams fall back to H.265 + * passthrough (no encode) until the host cools. Existing encodes are left to + * finish on their own — short live-view sessions — rather than killed mid-frame. + * + * If the temperature source is unreadable (non-Pi host, sandbox without sysfs), + * the governor stays inert and NEVER throttles — it can only ever make the + * transcode path more conservative, never break it on a host it can't measure. + * + * @module utils/thermal-governor + */ + +import * as fs from "fs"; +import { Logger, ILogObj } from "tslog"; + +export type ThermalLevel = "normal" | "warn" | "critical"; + +/** + * Read the host CPU temperature in °C from the standard Linux thermal sysfs + * node, or null if it can't be read or looks bogus. sysfs reports milli-°C. + */ +export function readCpuTempC(): number | null { + try { + const raw = fs + .readFileSync("/sys/class/thermal/thermal_zone0/temp", "utf8") + .trim(); + const milli = parseInt(raw, 10); + if (!Number.isFinite(milli)) return null; + const c = milli / 1000; + // sysfs is milli-°C; reject absurd values (bad node, different unit). + if (c < 0 || c > 150) return null; + return c; + } catch { + return null; + } +} + +export interface ThermalGovernorOptions { + /** Temperature source (injectable for tests). Defaults to the sysfs reader. */ + readTempC?: () => number | null; + logger?: Logger; + /** Surface a user-visible alert on a level change (e.g. Scrypted `log.a`). */ + onAlert?: (level: ThermalLevel, tempC: number, message: string) => void; + /** Enter WARN at/above this (°C). */ + warnC?: number; + /** Enter CRITICAL at/above this (°C); throttling begins here. */ + criticalC?: number; + /** Drop out of CRITICAL once below this (°C) — hysteresis. */ + clearCriticalC?: number; + /** Drop out of WARN once below this (°C) — hysteresis. */ + clearWarnC?: number; +} + +export class ThermalGovernor { + private level: ThermalLevel = "normal"; + private lastTempC: number | null = null; + private timer?: ReturnType; + + private readonly readTempC: () => number | null; + private readonly logger?: Logger; + private readonly onAlert?: ThermalGovernorOptions["onAlert"]; + private readonly warnC: number; + private readonly criticalC: number; + private readonly clearCriticalC: number; + private readonly clearWarnC: number; + + constructor(opts: ThermalGovernorOptions = {}) { + this.readTempC = opts.readTempC ?? readCpuTempC; + this.logger = opts.logger; + this.onAlert = opts.onAlert; + // Pi 4/5 hardware-throttle around 80–85°C; warn/critical sit below that + // with hysteresis so we act before the kernel does, without flapping. + this.warnC = opts.warnC ?? 70; + this.criticalC = opts.criticalC ?? 78; + this.clearCriticalC = opts.clearCriticalC ?? 72; + this.clearWarnC = opts.clearWarnC ?? 66; + } + + /** Sample the temperature once and apply any level transition. */ + tick(): void { + const t = this.readTempC(); + this.lastTempC = t; + // Unreadable → never throttle; relax back to normal silently. + if (t === null) { + this.level = "normal"; + return; + } + const next = this.computeLevel(t); + if (next !== this.level) this.transition(next, t); + } + + /** Next level given the current level, applying hysteresis on the way down. */ + private computeLevel(t: number): ThermalLevel { + switch (this.level) { + case "critical": + if (t >= this.clearCriticalC) return "critical"; + return t < this.clearWarnC ? "normal" : "warn"; + case "warn": + if (t >= this.criticalC) return "critical"; + return t < this.clearWarnC ? "normal" : "warn"; + default: // normal + if (t >= this.criticalC) return "critical"; + return t >= this.warnC ? "warn" : "normal"; + } + } + + private transition(next: ThermalLevel, t: number): void { + const prev = this.level; + this.level = next; + const temp = `${t.toFixed(1)}°C`; + if (next === "critical") { + const msg = `🌡️ CPU critically hot (${temp}) — throttling new H.264 transcodes (serving H.265 passthrough) until it cools`; + this.logger?.error(msg); + this.onAlert?.(next, t, msg); + } else if (next === "warn") { + const msg = `🌡️ CPU warm (${temp}) — watch concurrent transcodes; will throttle above ${this.criticalC}°C`; + this.logger?.warn(msg); + this.onAlert?.(next, t, msg); + } else if (prev !== "normal") { + const msg = `🌡️ CPU back to normal (${temp}) — transcoding unthrottled`; + this.logger?.info(msg); + this.onAlert?.(next, t, msg); + } + } + + /** True only while critically hot — new transcodes should be skipped. */ + shouldThrottleTranscode(): boolean { + return this.level === "critical"; + } + + get temperatureC(): number | null { + return this.lastTempC; + } + + get thermalLevel(): ThermalLevel { + return this.level; + } + + /** Begin periodic sampling. Idempotent. */ + start(intervalMs = 10000): void { + if (this.timer) return; + this.tick(); + this.timer = setInterval(() => this.tick(), intervalMs); + // Don't keep the event loop alive just for the thermometer. + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } +} + +// ---- Process-wide singleton ------------------------------------------------ + +let governor: ThermalGovernor | undefined; + +/** Start (once) the shared thermal governor. Returns the singleton. */ +export function startThermalGovernor( + opts: ThermalGovernorOptions = {}, +): ThermalGovernor { + if (!governor) governor = new ThermalGovernor(opts); + governor.start(); + return governor; +} + +/** Whether new H.264 transcodes should be skipped to protect the host. */ +export function isTranscodeThermallyThrottled(): boolean { + return governor?.shouldThrottleTranscode() ?? false; +} + +/** Current thermal snapshot for diagnostics/settings display. */ +export function getThermalState(): { + level: ThermalLevel; + tempC: number | null; +} { + return { + level: governor?.thermalLevel ?? "normal", + tempC: governor?.temperatureC ?? null, + }; +} + +/** Test-only: tear down the singleton. */ +export function _resetThermalGovernor(): void { + governor?.stop(); + governor = undefined; +} diff --git a/packages/eufy-security-scrypted/tests/unit/utils/thermal-governor.test.ts b/packages/eufy-security-scrypted/tests/unit/utils/thermal-governor.test.ts new file mode 100644 index 0000000..9478ef7 --- /dev/null +++ b/packages/eufy-security-scrypted/tests/unit/utils/thermal-governor.test.ts @@ -0,0 +1,129 @@ +/** + * Thermal governor tests + */ + +import { + ThermalGovernor, + ThermalLevel, + startThermalGovernor, + isTranscodeThermallyThrottled, + getThermalState, + _resetThermalGovernor, +} from "../../../src/utils/thermal-governor"; + +describe("ThermalGovernor", () => { + let temp: number | null; + const make = (onAlert?: any) => + new ThermalGovernor({ readTempC: () => temp, onAlert }); + + beforeEach(() => { + temp = 50; + }); + + it("starts normal and does not throttle when cool", () => { + const g = make(); + temp = 55; + g.tick(); + expect(g.thermalLevel).toBe("normal"); + expect(g.shouldThrottleTranscode()).toBe(false); + expect(g.temperatureC).toBe(55); + }); + + it("enters warn at the warn threshold, critical at the critical threshold", () => { + const g = make(); + temp = 70; + g.tick(); + expect(g.thermalLevel).toBe("warn"); + expect(g.shouldThrottleTranscode()).toBe(false); + + temp = 78; + g.tick(); + expect(g.thermalLevel).toBe("critical"); + expect(g.shouldThrottleTranscode()).toBe(true); + }); + + it("uses hysteresis: stays critical until well below the critical threshold", () => { + const g = make(); + temp = 80; + g.tick(); + expect(g.thermalLevel).toBe("critical"); + + // Just below criticalC (78) but above clearCriticalC (72) → still critical. + temp = 75; + g.tick(); + expect(g.thermalLevel).toBe("critical"); + expect(g.shouldThrottleTranscode()).toBe(true); + + // Below clearCriticalC but above clearWarnC (66) → warn (no throttle). + temp = 71; + g.tick(); + expect(g.thermalLevel).toBe("warn"); + expect(g.shouldThrottleTranscode()).toBe(false); + + // Below clearWarnC → normal. + temp = 60; + g.tick(); + expect(g.thermalLevel).toBe("normal"); + }); + + it("never throttles when the temperature is unreadable, even after being hot", () => { + const g = make(); + temp = 85; + g.tick(); + expect(g.shouldThrottleTranscode()).toBe(true); + + temp = null; + g.tick(); + expect(g.thermalLevel).toBe("normal"); + expect(g.shouldThrottleTranscode()).toBe(false); + expect(g.temperatureC).toBeNull(); + }); + + it("fires an alert on each level transition", () => { + const alerts: ThermalLevel[] = []; + const g = make((level: ThermalLevel) => alerts.push(level)); + + temp = 60; + g.tick(); // normal → normal: no alert + temp = 72; + g.tick(); // → warn + temp = 80; + g.tick(); // → critical + temp = 50; + g.tick(); // → normal + + expect(alerts).toEqual(["warn", "critical", "normal"]); + }); + + it("respects custom thresholds", () => { + temp = 60; + const g = new ThermalGovernor({ + readTempC: () => temp, + warnC: 55, + criticalC: 58, + }); + g.tick(); + expect(g.thermalLevel).toBe("critical"); + }); + + describe("singleton helpers", () => { + afterEach(() => _resetThermalGovernor()); + + it("isTranscodeThermallyThrottled reflects the running governor", () => { + let t = 50; + const g = startThermalGovernor({ readTempC: () => t }); + expect(isTranscodeThermallyThrottled()).toBe(false); + + t = 90; + g.tick(); + expect(isTranscodeThermallyThrottled()).toBe(true); + expect(getThermalState()).toEqual({ level: "critical", tempC: 90 }); + }); + + it("isTranscodeThermallyThrottled is false when no governor is running", () => { + _resetThermalGovernor(); + expect(isTranscodeThermallyThrottled()).toBe(false); + expect(getThermalState()).toEqual({ level: "normal", tempC: null }); + }); + }); +});