Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
593 changes: 592 additions & 1 deletion packages/eufy-security-scrypted/src/eufy-device.ts

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions packages/eufy-security-scrypted/src/eufy-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
}

Expand All @@ -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) {
Expand All @@ -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<MediaObject> {
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
*/
Expand Down
156 changes: 152 additions & 4 deletions packages/eufy-security-scrypted/src/services/device/stream-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -47,13 +48,47 @@ 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.
* @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<ILogObj>,
private shouldTranscode: () => boolean = () => false,
private isThrottling: () => boolean = () => false,
) {}

/**
* 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 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
*
Expand Down Expand Up @@ -83,9 +118,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 [
{
Expand Down Expand Up @@ -137,15 +177,118 @@ 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",
);
} 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);
}

/**
* 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<number | undefined> {
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<MediaObject> {
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<void> {
if (this.transcodeServer) {
await this.transcodeServer.stop();
}
if (this.streamServerStarted) {
this.logger.info("Stopping stream server");
await this.streamServer.stop();
Expand Down Expand Up @@ -236,10 +379,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" } }),
},
Expand Down
11 changes: 11 additions & 0 deletions packages/eufy-security-scrypted/src/services/device/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ export interface IStreamServer {
*/
captureSnapshot(timeout?: number): Promise<Buffer>;

/**
* 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.
Expand Down
Loading
Loading