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
152 changes: 152 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

This is a Homebridge plugin that provides FFmpeg-based camera support for Apple HomeKit. It enables IP cameras to be integrated into HomeKit with support for video streaming, snapshots, motion detection, doorbell functionality, and HomeKit Secure Video recording.

## Development Commands

### Essential Commands
- `npm run build` - Compile TypeScript to JavaScript (outputs to `dist/`)
- `npm run lint` - Run ESLint on source files
- `npm run lint:fix` - Auto-fix linting issues
- `npm test` - Run all tests with Vitest
- `npm run test:watch` - Run tests in watch mode
- `npm run test-coverage` - Run tests with coverage report
- `npm run clean` - Remove the dist folder

### Development Workflow
- `npm run watch` - Build, copy plugin UI files, link locally, and watch for changes with nodemon
- `npm run plugin-ui` - Copy Homebridge UI files from `src/homebridge-ui/public/` to `dist/homebridge-ui/public/`

### Note on Testing
The project uses Vitest for testing. Test files are excluded from the TypeScript compilation (see `tsconfig.json`).

## Architecture Overview

### Core Components

**Platform (platform.ts)**
- Entry point that registers with Homebridge as a dynamic platform plugin
- Manages camera accessories lifecycle (creation, configuration, caching)
- Handles automation integrations (MQTT client, HTTP server)
- Routes motion/doorbell events to appropriate handlers
- Maintains timers for motion/doorbell resets

**Streaming Delegate (streamingDelegate.ts)**
- Implements HomeKit camera streaming protocol
- Manages video/audio RTP streams via FFmpeg
- Handles snapshot requests (supports both HTTP URLs and FFmpeg extraction)
- Coordinates session management (pending, ongoing, timeouts)
- Creates and manages the recording delegate if HSV is enabled

**Recording Delegate (recordingDelegate.ts)**
- Implements HomeKit Secure Video (HSV) recording
- Generates fragmented MP4 streams with proper H.264 profiles/levels
- Handles recording configuration updates from HomeKit
- Works with prebuffering to capture pre-motion video
- Uses async generators to yield MP4 fragments (ftyp, moov, moof, mdat boxes)

**PreBuffer (prebuffer.ts)**
- Maintains a rolling buffer of recent video for HSV
- Runs a persistent FFmpeg process that outputs fragmented MP4
- Stores atoms with timestamps for time-based retrieval
- Provides video segments on-demand when recording is triggered

**FFmpeg Process (ffmpeg.ts)**
- Wrapper around FFmpeg child processes
- Parses progress output for monitoring
- Handles process lifecycle (start, stop, error handling)
- Supports motion detection via scene change analysis
- Logs performance metrics and errors

### Data Flow

1. **Live Streaming**: HomeKit → StreamingDelegate → FFmpeg → RTP/SRTP stream
2. **Snapshots**: HomeKit → StreamingDelegate → FFmpeg (or direct HTTP fetch) → JPEG
3. **HSV Recording**: Motion event → RecordingDelegate → PreBuffer → FFmpeg → MP4 fragments → HomeKit
4. **Motion Detection**: FFmpeg scene filter → FfmpegProcess callback → Platform motion handler
5. **Automation**: MQTT/HTTP → Platform handlers → Update HomeKit characteristics

### Key Design Patterns

- **Delegate Pattern**: Streaming and recording delegates implement HomeKit protocols
- **Session Management**: Maps track active streaming/recording sessions by session ID
- **Async Generators**: Used for streaming MP4 atoms from FFmpeg output
- **Event-based**: PreBuffer uses EventEmitter to notify consumers of new video atoms

## Important Configuration Details

### Camera Configuration Structure
Cameras are configured in the platform config with these key sections:
- `videoConfig.source` - FFmpeg input arguments (required, must include `-i`)
- `videoConfig.subSource` - Lower resolution stream for motion detection
- `videoConfig.stillImageSource` - Direct URL or FFmpeg source for snapshots
- HTTP/HTTPS URLs: `"http://camera.local/snapshot.jpg"` (no `-i` needed)
- FFmpeg sources: `"-i rtsp://camera.local:554/stream"` (requires `-i`)
- `videoConfig.recording` - Enables HomeKit Secure Video
- `videoConfig.prebuffer` - Enables video prebuffering for HSV (requires recording)

### Configuration Validation
The plugin validates camera configurations at startup (platform.ts constructor):
- Checks for required fields (name, source)
- Validates FFmpeg arguments include `-i` flag
- **Intelligently skips `-i` validation** for direct HTTP/HTTPS URLs in `stillImageSource`
- Uses the same URL detection regex as snapshot fetching (`/^https?:\/\/[^\s]+$/`)

### FFmpeg Path Resolution
The plugin uses this hierarchy for finding FFmpeg:
1. `videoProcessor` from platform or camera config
2. `defaultFfmpegPath` from `@homebridge/camera-utils` package
3. System `ffmpeg` command

### TypeScript Configuration
- Target: ES2022 with ES Modules (`"type": "module"` in package.json)
- Module resolution: bundler mode
- Strict mode enabled, but `noImplicitAny` is disabled
- Source maps and declarations generated

## Working with This Codebase

### Adding Features
- Camera features require updates to: settings.ts (types), config.schema.json (UI), platform.ts or streamingDelegate.ts (logic)
- HSV features primarily live in recordingDelegate.ts and prebuffer.ts
- Motion/doorbell automation touches platform.ts event handlers and ffmpeg.ts for detection

### Snapshot Fetching
The plugin intelligently chooses between two methods for fetching snapshots:

**Direct HTTP/HTTPS Fetch** (streamingDelegate.ts:fetchSnapshot)
- Used when `stillImageSource` is a plain URL (matches `/^https?:\/\/[^\s]+$/`)
- Fetches images directly using Node's native `http`/`https` modules
- Much faster than FFmpeg (no process spawn overhead)
- 10-second timeout with proper error handling
- Example: `"stillImageSource": "http://192.168.1.100/snapshot.jpg"`

**FFmpeg-based Fetch**
- Used for RTSP streams or FFmpeg-style arguments
- Required when video filters need to be applied
- Handles complex sources that require decoding
- Example: `"stillImageSource": "-i rtsp://192.168.1.100:554/stream"`

The detection is automatic and transparent to the user.

### FFmpeg Integration
- All FFmpeg commands are split by whitespace and passed as argv arrays
- Debug logging can be enabled per-camera via `videoConfig.debug`
- FFmpeg processes output progress to stdout and logs to stderr
- Scene-based motion detection uses the `select` filter with metadata output

### Session Management
Sessions have distinct lifecycle stages:
- Pending: Created during prepareStream, stores crypto/network info
- Ongoing: Active after startStream, contains FFmpeg processes and sockets
- Cleanup: Happens on stopStream or controller.forceStopStreamingSession

### Common Patterns
- All camera names are required; the platform generates defaults if missing
- UUID generation uses `api.hap.uuid.generate(cameraName)` for consistency
- Services are removed and recreated on configuration changes (not updated in-place)
- Accessories are published as external (unbridged by default) via `publishExternalAccessories`
13 changes: 10 additions & 3 deletions src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,16 @@ export class FfmpegPlatform implements DynamicPlatformPlugin {
}
}
if (cameraConfig.videoConfig.stillImageSource) {
const stillArgs = cameraConfig.videoConfig.stillImageSource.split(/\s+/)
if (!stillArgs.includes('-i')) {
this.log.warn('The stillImageSource for this camera is missing "-i", it is likely misconfigured.', cameraConfig.name)
const stillSource = cameraConfig.videoConfig.stillImageSource.trim()
// Check if it's a direct HTTP/HTTPS URL (doesn't need -i)
const isDirectUrl = /^https?:\/\/[^\s]+$/.test(stillSource)

if (!isDirectUrl) {
// Only validate FFmpeg-style sources
const stillArgs = stillSource.split(/\s+/)
if (!stillArgs.includes('-i')) {
this.log.warn('The stillImageSource for this camera is missing "-i", it is likely misconfigured.', cameraConfig.name)
}
}
}
if (cameraConfig.videoConfig.vcodec === 'copy' && cameraConfig.videoConfig.videoFilter) {
Expand Down
142 changes: 102 additions & 40 deletions src/streamingDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { Logger } from './logger.js'
import { Buffer } from 'node:buffer'
import { spawn } from 'node:child_process'
import { createSocket, Socket } from 'node:dgram'
import http from 'node:http'
import https from 'node:https'
import { env } from 'node:process'

import { defaultFfmpegPath } from '@homebridge/camera-utils'
Expand Down Expand Up @@ -193,53 +195,113 @@ export class StreamingDelegate implements CameraStreamingDelegate {
fetchSnapshot(snapFilter?: string): Promise<Buffer> {
this.snapshotPromise = new Promise((resolve, reject) => {
const startTime = Date.now()
const ffmpegArgs = `${this.videoConfig.stillImageSource || this.videoConfig.source! // Still
} -frames:v 1${snapFilter ? ` -filter:v ${snapFilter}` : ''
} -f image2 -`
+ ` -hide_banner`
+ ` -loglevel error`
const source = this.videoConfig.stillImageSource || this.videoConfig.source!

// Check if source is a direct HTTP/HTTPS URL (not FFmpeg args)
// A direct URL doesn't contain spaces and starts with http:// or https://
const isDirectUrl = /^https?:\/\/[^\s]+$/.test(source.trim())

if (isDirectUrl) {
// Direct HTTP/HTTPS fetch - much faster and more efficient
this.log.debug(`Fetching snapshot via HTTP: ${source}`, this.cameraName, this.videoConfig.debug)
const client = source.startsWith('https') ? https : http

const req = client.get(source, { timeout: 10000 }, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`Failed to fetch snapshot, HTTP status: ${res.statusCode}`))
res.resume()
return
}

this.log.debug(`Snapshot command: ${this.videoProcessor} ${ffmpegArgs}`, this.cameraName, this.videoConfig.debug)
const ffmpeg = spawn(this.videoProcessor, ffmpegArgs.split(/\s+/), { env })
const chunks: Buffer[] = []
res.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
})

res.on('end', () => {
const snapshotBuffer = Buffer.concat(chunks)
if (snapshotBuffer.length > 0) {
resolve(snapshotBuffer)
} else {
reject(new Error('Failed to fetch snapshot: empty response'))
}

setTimeout(() => {
this.snapshotPromise = undefined
}, 3 * 1000) // Expire cached snapshot after 3 seconds

const runtime = (Date.now() - startTime) / 1000
let message = `Fetching snapshot took ${runtime} seconds.`
if (runtime < 5) {
this.log.debug(message, this.cameraName, this.videoConfig.debug)
} else {
if (runtime < 22) {
this.log.warn(message, this.cameraName)
} else {
message += ' The request has timed out and the snapshot has not been refreshed in HomeKit.'
this.log.error(message, this.cameraName)
}
}
})
})

let snapshotBuffer = Buffer.alloc(0)
ffmpeg.stdout.on('data', (data) => {
snapshotBuffer = Buffer.concat([snapshotBuffer, data])
})
ffmpeg.on('error', (error: Error) => {
reject(new Error(`FFmpeg process creation failed: ${error.message}`))
})
ffmpeg.stderr.on('data', (data) => {
data.toString().split('\n').forEach((line: string) => {
if (this.videoConfig.debug && line.length > 0) { // For now only write anything out when debug is set
this.log.error(line, `${this.cameraName}] [Snapshot`)
}
req.on('error', (err) => {
reject(new Error(`HTTP snapshot error: ${err.message}`))
})
})
ffmpeg.on('close', () => {
if (snapshotBuffer.length > 0) {
resolve(snapshotBuffer)
} else {
reject(new Error('Failed to fetch snapshot.'))
}

setTimeout(() => {
this.snapshotPromise = undefined
}, 3 * 1000) // Expire cached snapshot after 3 seconds
req.on('timeout', () => {
req.destroy()
reject(new Error('HTTP snapshot request timed out'))
})
} else {
// Use FFmpeg for RTSP streams, FFmpeg args, or when filters are needed
const ffmpegArgs = `${source} -frames:v 1${snapFilter ? ` -filter:v ${snapFilter}` : ''
} -f image2 -`
+ ` -hide_banner`
+ ` -loglevel error`

this.log.debug(`Snapshot command: ${this.videoProcessor} ${ffmpegArgs}`, this.cameraName, this.videoConfig.debug)
const ffmpeg = spawn(this.videoProcessor, ffmpegArgs.split(/\s+/), { env })

const runtime = (Date.now() - startTime) / 1000
let message = `Fetching snapshot took ${runtime} seconds.`
if (runtime < 5) {
this.log.debug(message, this.cameraName, this.videoConfig.debug)
} else {
if (runtime < 22) {
this.log.warn(message, this.cameraName)
let snapshotBuffer = Buffer.alloc(0)
ffmpeg.stdout.on('data', (data) => {
snapshotBuffer = Buffer.concat([snapshotBuffer, data])
})
ffmpeg.on('error', (error: Error) => {
reject(new Error(`FFmpeg process creation failed: ${error.message}`))
})
ffmpeg.stderr.on('data', (data) => {
data.toString().split('\n').forEach((line: string) => {
if (this.videoConfig.debug && line.length > 0) {
this.log.error(line, `${this.cameraName}] [Snapshot`)
}
})
})
ffmpeg.on('close', () => {
if (snapshotBuffer.length > 0) {
resolve(snapshotBuffer)
} else {
message += ' The request has timed out and the snapshot has not been refreshed in HomeKit.'
this.log.error(message, this.cameraName)
reject(new Error('Failed to fetch snapshot.'))
}
}
})

setTimeout(() => {
this.snapshotPromise = undefined
}, 3 * 1000) // Expire cached snapshot after 3 seconds

const runtime = (Date.now() - startTime) / 1000
let message = `Fetching snapshot took ${runtime} seconds.`
if (runtime < 5) {
this.log.debug(message, this.cameraName, this.videoConfig.debug)
} else {
if (runtime < 22) {
this.log.warn(message, this.cameraName)
} else {
message += ' The request has timed out and the snapshot has not been refreshed in HomeKit.'
this.log.error(message, this.cameraName)
}
}
})
}
})
return this.snapshotPromise
}
Expand Down