Files
eye-spy/services/camera.mjs
T
2026-10-22 02:02:09 -04:00

1096 lines
34 KiB
JavaScript

// Camera service (rpicam-apps wrapper)
//
// libcamera lets only ONE process own the camera, so everything here goes through a single
// state machine:
//
// ┌──────────── clients connect ────────────┐
// ▼ │
// idle ◀──────▶ streaming ──photo()/video()/timelapse()──▶ photo | video | timelapse
// ▲ last client leaves │
// └────────── job finished (stream auto-resumes if anyone is watching) ◀──┘
//
// * One shared rpicam-vid MJPEG process fans frames out to every HTTP client.
// * Photo / video / timelapse take exclusive ownership: the stream is stopped (clients stay
// connected and simply freeze), the job runs, then the stream restarts by itself.
// * A second job while one is running is rejected with 409 "camera_busy".
//
// Events: 'state', 'settings', 'focus', 'capture', 'timelapse'
// Camera service (rpicam-apps wrapper)
import { EventEmitter } from 'node:events';
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { ServiceError } from '../lib/errors.mjs';
const BOUNDARY = 'frame';
const SOI = Buffer.from([0xff, 0xd8]);
const EOI = Buffer.from([0xff, 0xd9]);
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
const DEFAULT_SETTINGS = {
shutterUs: 0,
gain: 0,
ev: 0,
exposureMode: 'normal',
brightness: 0,
contrast: 1,
saturation: 1,
sharpness: 1,
awb: 'auto',
awbGains: null,
denoise: 'auto',
autofocusMode: 'manual',
autofocusRange: 'normal',
autofocusSpeed: 'normal',
lensPosition: 0,
hflip: false,
vflip: false,
rotation: 0,
quality: 93,
roi: null,
};
const num = (min, max) => (key, v) => {
const n = Number(v);
if (v === null || v === '' || !Number.isFinite(n) || n < min || n > max) {
throw new ServiceError('invalid_setting', `"${key}" must be a number between ${min} and ${max}`, 400);
}
return n;
};
const oneOf = (...opts) => (key, v) => {
if (!opts.includes(v)) {
throw new ServiceError('invalid_setting', `"${key}" must be one of: ${opts.join(', ')}`, 400);
}
return v;
};
const bool = (key, v) => {
if (typeof v !== 'boolean') {
throw new ServiceError('invalid_setting', `"${key}" must be true or false`, 400);
}
return v;
};
const SETTING_PARSERS = {
shutterUs: (k, v) => Math.round(num(0, 240_000_000)(k, v)),
gain: num(0, 64),
ev: num(-10, 10),
exposureMode: oneOf('normal', 'sport', 'long'),
brightness: num(-1, 1),
contrast: num(0, 32),
saturation: num(0, 32),
sharpness: num(0, 16),
awb: oneOf('auto', 'incandescent', 'tungsten', 'fluorescent', 'indoor', 'daylight', 'cloudy'),
awbGains: (k, v) => {
if (v === null) return null;
if (!Array.isArray(v) || v.length !== 2) {
throw new ServiceError('invalid_setting', `"${k}" must be [red, blue] or null`, 400);
}
return v.map((x) => num(0.1, 8)(k, x));
},
denoise: oneOf('auto', 'off', 'cdn_off', 'cdn_fast', 'cdn_hq'),
autofocusMode: oneOf('manual', 'auto', 'continuous'),
autofocusRange: oneOf('normal', 'macro', 'full'),
autofocusSpeed: oneOf('normal', 'fast'),
lensPosition: (k, v) => (v === null ? null : num(0, 32)(k, v)),
hflip: bool,
vflip: bool,
rotation: oneOf(0, 180),
quality: (k, v) => Math.round(num(1, 100)(k, v)),
roi: (k, v) => {
if (v === null) return null;
if (!Array.isArray(v) || v.length !== 4) {
throw new ServiceError('invalid_setting', `"${k}" must be [x, y, w, h] (0..1) or null`, 400);
}
const r = v.map((x) => num(0, 1)(k, x));
if (r[2] <= 0 || r[3] <= 0 || r[0] + r[2] > 1.0001 || r[1] + r[3] > 1.0001) {
throw new ServiceError('invalid_setting', `"${k}" region must lie inside the sensor`, 400);
}
return r;
},
};
/** Validate partial settings. ISO is a convenience alias for gain, not an independent sensor control. */
export function normalizeSettings(patch) {
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
throw new ServiceError('invalid_setting', 'Settings must be a JSON object', 400);
}
const out = {};
for (const [key, value] of Object.entries(patch)) {
if (key === 'iso') {
out.gain = value === 0 ? 0 : num(1, 6400)('iso', value) / 100;
continue;
}
const parse = SETTING_PARSERS[key];
if (!parse) {
throw new ServiceError('unknown_setting', `Unknown setting "${key}"`, 400, {
valid: [...Object.keys(SETTING_PARSERS), 'iso'],
});
}
out[key] = parse(key, value);
}
return out;
}
class MjpegParser {
#buf = Buffer.alloc(0);
constructor(onFrame) {
this.onFrame = onFrame;
}
push(chunk) {
this.#buf = this.#buf.length ? Buffer.concat([this.#buf, chunk]) : chunk;
for (;;) {
const soi = this.#buf.indexOf(SOI);
if (soi === -1) {
this.#buf = this.#buf.length && this.#buf[this.#buf.length - 1] === 0xff
? this.#buf.subarray(-1)
: Buffer.alloc(0);
return;
}
const eoi = this.#buf.indexOf(EOI, soi + 2);
if (eoi === -1) {
this.#buf = this.#buf.subarray(soi);
if (this.#buf.length > MAX_FRAME_BYTES) this.#buf = Buffer.alloc(0);
return;
}
this.onFrame(this.#buf.subarray(soi, eoi + 2));
this.#buf = this.#buf.subarray(eoi + 2);
}
}
}
const stamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
const sleepable = () => {
let wake;
const promise = new Promise((resolve) => (wake = resolve));
return { promise, wake };
};
export class CameraService extends EventEmitter {
#cfg;
#log;
#dirs;
#settingsFile;
#metaProvider;
#settings = { ...DEFAULT_SETTINGS };
#mode = 'idle';
#locked = false;
#stream = null;
#clients = new Set();
#lastFrame = null;
#idleTimer = null;
#restartTimer = null;
#streamFailures = 0;
#job = null;
#activeProc = null;
#lastCapture = null;
#ffmpegAvailable = null;
#focus;
constructor(cfg, { log, dataDir, metaProvider }) {
super();
this.#cfg = cfg;
this.#log = log;
this.#metaProvider = metaProvider;
const base = path.join(dataDir, 'captures');
this.#dirs = {
base,
photos: path.join(base, 'photos'),
videos: path.join(base, 'videos'),
timelapse: path.join(base, 'timelapse'),
};
this.#settingsFile = path.join(dataDir, 'camera-settings.json');
this.#focus = {
position: 0,
target: 0,
min: 0,
max: cfg.focusMaxSteps,
hardware: false,
moving: false,
};
}
get mediaRoot() {
return this.#dirs.base;
}
async init() {
for (const dir of Object.values(this.#dirs)) await fsp.mkdir(dir, { recursive: true });
try {
const saved = JSON.parse(await fsp.readFile(this.#settingsFile, 'utf8'));
this.#settings = { ...DEFAULT_SETTINGS, ...normalizeSettings(saved) };
this.#log.info('loaded saved camera settings');
} catch (err) {
if (err.code !== 'ENOENT') this.#log.warn(`ignoring unreadable camera settings: ${err.message}`);
}
}
async shutdown() {
clearTimeout(this.#idleTimer);
clearTimeout(this.#restartTimer);
if (this.#job?.type === 'timelapse') await this.stopTimelapse().catch(() => {});
if (this.#job?.type === 'video') await this.stopVideo().catch(() => {});
await this.#stopStream();
}
// ───────────────────────────── status / settings ─────────────────────────────
getSettings() {
return {
...this.#settings,
iso: this.#settings.gain ? Math.round(this.#settings.gain * 100) : 0,
};
}
getStatus() {
return {
mode: this.#mode,
busy: this.#locked,
streamClients: this.#clients.size,
stream: this.#stream
? { running: true, fps: this.#stream.fps, width: this.#cfg.width, height: this.#cfg.height }
: { running: false },
settings: this.getSettings(),
focus: this.getFocus(),
job: this.#jobSummary(),
lastCapture: this.#lastCapture,
};
}
/** Patch any subset of settings, persist them, and restart the live stream if needed. */
async applySettings(patch) {
const clean = normalizeSettings(patch);
this.#settings = { ...this.#settings, ...clean };
try {
await fsp.writeFile(this.#settingsFile, JSON.stringify(this.#settings, null, 2));
} catch (err) {
this.#log.warn(`could not persist camera settings: ${err.message}`);
}
this.#log.info('camera settings updated', clean);
this.emit('settings', this.getSettings());
this.#clearError();
if (this.#stream && !this.#locked) {
clearTimeout(this.#restartTimer);
this.#restartTimer = setTimeout(async () => {
if (this.#locked) return;
await this.#stopStream();
this.#ensureStream();
}, 300);
}
return this.getSettings();
}
/** Restore and persist all defaults. */
async resetSettings() {
return this.applySettings({ ...DEFAULT_SETTINGS });
}
// ───────────────────────────── focuser ─────────────────────────────
getFocus() {
return { ...this.#focus };
}
/** Stub for the telescope focuser hardware interface. */
async setFocus(position) {
const n = Number(position);
if (!Number.isFinite(n)) {
throw new ServiceError('invalid_focus', '"position" must be a number', 400);
}
const f = this.#focus;
const target = Math.min(f.max, Math.max(f.min, Math.round(n)));
const delta = target - f.position;
this.#log.info(
`[focuser:stub] move ${f.position} -> ${target} (${delta >= 0 ? '+' : ''}${delta} steps)` +
(target !== Math.round(n) ? ` (clamped from ${Math.round(n)})` : '') +
' — no focuser hardware attached',
);
f.target = target;
f.position = target;
this.emit('focus', this.getFocus());
return this.getFocus();
}
async nudgeFocus(delta) {
const n = Number(delta);
if (!Number.isFinite(n)) {
throw new ServiceError('invalid_focus', '"delta" must be a number', 400);
}
return this.setFocus(this.#focus.position + n);
}
// ───────────────────────────── live stream ─────────────────────────────
addStreamClient(req, res) {
res.writeHead(200, {
'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
Connection: 'close',
});
res.socket?.setNoDelay(true);
this.#clients.add(res);
clearTimeout(this.#idleTimer);
this.#clearError();
this.#log.debug(`stream client connected (${this.#clients.size})`);
this.emit('state', this.getStatus());
if (this.#lastFrame) this.#sendFrame(res, this.#lastFrame);
this.#ensureStream();
let gone = false;
const onGone = () => {
if (gone) return;
gone = true;
this.#clients.delete(res);
this.#log.debug(`stream client left (${this.#clients.size})`);
this.#scheduleIdleStop();
this.emit('state', this.getStatus());
};
req.on('close', onGone);
res.on('error', onGone);
}
getSnapshot() {
return this.#lastFrame;
}
#clearError() {
this.#streamFailures = 0;
if (this.#mode === 'error' && !this.#stream && !this.#locked) this.#setMode('idle');
}
#ensureStream() {
if (this.#locked || this.#stream || this.#clients.size === 0 || this.#mode === 'error') return;
this.#startStream();
}
#scheduleIdleStop() {
if (this.#clients.size > 0) return;
clearTimeout(this.#idleTimer);
this.#idleTimer = setTimeout(async () => {
if (this.#clients.size === 0 && !this.#locked) {
await this.#stopStream();
this.#setMode('idle');
}
}, this.#cfg.streamIdleStopMs);
}
// Resolve exposure and frame timing once so preview and recording don't silently
// choose different shutter speeds or incompatible frame rates.
#cameraPlan(settings, requestedFps) {
const shutterUs = settings.shutterUs > 0 ? settings.shutterUs : 0;
const fps = shutterUs > 0
? Math.min(Number(requestedFps), 1_000_000 / shutterUs)
: Number(requestedFps);
return { settings, shutterUs, fps: Math.max(0.001, fps) };
}
#streamArgs() {
const c = this.#cfg;
const plan = this.#cameraPlan(this.#settings, c.fps);
const { settings, shutterUs, fps } = plan;
return {
fps,
args: [
'-n', '-t', '0',
'--width', String(c.width),
'--height', String(c.height),
'--framerate', fps.toFixed(3),
'--codec', 'mjpeg',
'-q', String(settings.quality),
...this.#controlArgs(settings, { shutterUs }),
'-o', '-',
],
};
}
#startStream() {
const { fps, args } = this.#streamArgs();
this.#log.info(`starting live view ${this.#cfg.width}x${this.#cfg.height} @ ${fps.toFixed(3)} fps`);
const proc = spawn(this.#cfg.vidBin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stream = { proc, fps, expected: false, startedAt: Date.now(), frames: 0, exited: null };
stream.exited = new Promise((resolve) => {
proc.once('close', resolve);
proc.once('error', resolve);
});
this.#stream = stream;
this.#setMode('streaming');
const parser = new MjpegParser((frame) => {
stream.frames += 1;
this.#lastFrame = Buffer.from(frame);
this.#broadcast(this.#lastFrame);
});
proc.stdout.on('data', (chunk) => parser.push(chunk));
proc.stderr.on('data', (data) => {
const message = String(data).trim();
if (/error|fail|unable|no cameras/i.test(message)) {
this.#log.error(`rpicam-vid: ${message.slice(0, 300)}`);
}
});
proc.on('error', (err) => {
this.#log.error(`cannot run ${this.#cfg.vidBin}: ${err.message}`);
if (this.#stream === stream) this.#stream = null;
this.#setMode('error');
});
proc.on('close', (code, signal) => {
if (this.#stream !== stream) return;
this.#stream = null;
this.#lastFrame = null;
this.#streamFailures += 1;
this.#log.warn(`live view exited unexpectedly (code=${code}, signal=${signal}, frames=${stream.frames})`);
if (this.#streamFailures >= 5) {
this.#log.error('live view failed 5 times in a row; giving up until a new viewer connects or settings change');
this.#setMode('error');
return;
}
this.#setMode('idle');
if (this.#clients.size && !this.#locked) {
clearTimeout(this.#restartTimer);
this.#restartTimer = setTimeout(() => this.#ensureStream(), 1000 * this.#streamFailures);
}
});
}
async #stopStream() {
const stream = this.#stream;
if (!stream) return;
this.#stream = null;
stream.expected = true;
stream.proc.kill('SIGINT');
const killer = setTimeout(() => stream.proc.kill('SIGKILL'), 3000);
await stream.exited;
clearTimeout(killer);
this.#lastFrame = null;
this.#log.debug('live view stopped');
}
#broadcast(frame) {
for (const res of this.#clients) this.#sendFrame(res, frame);
}
#sendFrame(res, frame) {
if (res.writableNeedDrain || res.destroyed) return;
res.write(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
res.write(frame);
res.write('\r\n');
}
// ───────────────────────────── exclusive jobs ─────────────────────────────
async #acquire(mode) {
if (this.#locked) {
throw new ServiceError('camera_busy', `Camera is busy (${this.#mode})`, 409, { mode: this.#mode });
}
this.#clearError();
this.#locked = true;
clearTimeout(this.#restartTimer);
clearTimeout(this.#idleTimer);
try {
await this.#stopStream();
} catch (err) {
this.#locked = false;
throw err;
}
this.#setMode(mode);
}
#release() {
this.#locked = false;
this.#job = null;
this.#setMode('idle');
this.#ensureStream();
}
#setMode(mode) {
if (this.#mode === mode) return;
const previous = this.#mode;
this.#mode = mode;
this.#log.debug(`camera ${previous} -> ${mode}`);
this.emit('state', this.getStatus());
}
#meta() {
try {
return this.#metaProvider?.() ?? null;
} catch (err) {
this.#log.warn(`metadata provider failed: ${err.message}`);
return null;
}
}
#run(bin, args, timeoutMs) {
return new Promise((resolve, reject) => {
const proc = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
this.#activeProc = proc;
let stderr = '';
let timedOut = false;
proc.stderr.on('data', (data) => {
stderr = (stderr + data).slice(-4000);
});
const timer = setTimeout(() => {
timedOut = true;
proc.kill('SIGKILL');
}, timeoutMs);
proc.on('error', (err) => {
clearTimeout(timer);
this.#activeProc = null;
reject(new ServiceError('camera_spawn_failed', `Cannot run ${bin}: ${err.message}`, 500));
});
proc.on('close', (code, signal) => {
clearTimeout(timer);
this.#activeProc = null;
resolve({ code, signal, stderr, timedOut });
});
});
}
// ───────────────────────────── photos ─────────────────────────────
#stillArgs(file, settings, { raw = false } = {}) {
const c = this.#cfg;
const { shutterUs } = this.#cameraPlan(settings, 1);
const manual = shutterUs > 0 && settings.gain > 0;
return [
'-n',
'-o', file,
'-q', String(settings.quality),
...(manual ? ['--immediate'] : ['-t', '1500']),
...(raw ? ['--raw'] : []),
...(c.photoWidth > 0 && c.photoHeight > 0
? ['--width', String(c.photoWidth), '--height', String(c.photoHeight)]
: []),
...this.#controlArgs(settings),
];
}
#stillTimeoutMs(settings) {
return Math.max(30_000, (settings.shutterUs / 1000) * 4 + 30_000);
}
async #captureStill(file, settings, opts) {
const started = Date.now();
const result = await this.#run(
this.#cfg.stillBin,
this.#stillArgs(file, settings, opts),
this.#stillTimeoutMs(settings),
);
if (result.code !== 0 || !fs.existsSync(file)) {
const reason = result.timedOut
? 'timed out'
: result.signal
? `killed (${result.signal})`
: `exit code ${result.code}`;
throw new ServiceError('capture_failed', `rpicam-still failed: ${reason}`, 500, {
stderr: result.stderr.trim().split('\n').slice(-5).join('\n'),
});
}
const stat = await fsp.stat(file);
return { file, bytes: stat.size, durationMs: Date.now() - started };
}
async takePhoto(overrides = {}) {
const { raw = false, ...settingOverrides } = overrides;
const settings = { ...this.#settings, ...normalizeSettings(settingOverrides) };
await this.#acquire('photo');
try {
const base = `photo_${stamp()}`;
const file = path.join(this.#dirs.photos, `${base}.jpg`);
this.#log.info(
`photo: ${settings.shutterUs ? `${(settings.shutterUs / 1e6).toFixed(2)}s` : 'auto'} exposure, ` +
`gain ${settings.gain || 'auto'}${raw ? ', +RAW' : ''}`,
);
const meta = this.#meta();
const result = await this.#captureStill(file, settings, { raw });
const info = {
type: 'photo',
name: path.basename(file),
url: `/media/photos/${path.basename(file)}`,
raw: raw ? `/media/photos/${base}.dng` : null,
bytes: result.bytes,
durationMs: result.durationMs,
takenAt: new Date().toISOString(),
settings: { ...settings, iso: settings.gain ? Math.round(settings.gain * 100) : 0 },
meta,
};
await fsp.writeFile(path.join(this.#dirs.photos, `${base}.json`), JSON.stringify(info, null, 2)).catch(() => {});
this.#lastCapture = { type: 'photo', url: info.url, at: info.takenAt };
this.#log.info(`photo saved: ${info.name} (${(info.bytes / 1e6).toFixed(1)} MB in ${(info.durationMs / 1000).toFixed(1)}s)`);
this.emit('capture', info);
return info;
} finally {
this.#release();
}
}
// ───────────────────────────── video ─────────────────────────────
async startVideo(opts = {}) {
const { durationSec = 0, width, height, fps, ...settingOverrides } = opts;
const settings = { ...this.#settings, ...normalizeSettings(settingOverrides) };
const c = this.#cfg;
if (!Number.isFinite(Number(durationSec)) || durationSec < 0) {
throw new ServiceError('invalid_option', '"durationSec" must be >= 0', 400);
}
await this.#acquire('video');
const w = width || c.videoWidth;
const h = height || c.videoHeight;
const requestedFps = Number(fps || c.videoFps);
const plan = this.#cameraPlan(settings, requestedFps);
const f = plan.fps;
const base = `video_${stamp()}`;
const rawFile = path.join(this.#dirs.videos, `${base}.h264`);
let proc;
try {
const args = [
'-n',
'-t', String(Math.round(durationSec * 1000)),
'--width', String(w),
'--height', String(h),
'--framerate', String(f),
'--codec', 'h264',
'--inline',
...this.#controlArgs(plan.settings, { shutterUs: plan.shutterUs }),
'-o', rawFile,
];
proc = spawn(c.vidBin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
} catch (err) {
this.#release();
throw new ServiceError('camera_spawn_failed', `Cannot run ${c.vidBin}: ${err.message}`, 500);
}
const job = {
type: 'video',
id: base,
file: rawFile,
startedAt: Date.now(),
durationSec,
proc,
stopping: false,
done: null,
};
this.#job = job;
let stderr = '';
proc.stderr.on('data', (data) => {
stderr = (stderr + data).slice(-2000);
});
job.done = new Promise((resolve) => {
const finish = async (code, signal, spawnErr) => {
let info;
try {
if (spawnErr || !fs.existsSync(rawFile)) {
info = {
type: 'video',
error: spawnErr?.message ?? `rpicam-vid produced no file (code=${code})`,
stderr: stderr.trim().split('\n').slice(-3).join('\n'),
};
this.#log.error(`video failed: ${info.error}`);
} else {
info = await this.#finishVideo(job, rawFile, base, f);
}
} finally {
this.#release();
}
resolve(info);
};
proc.once('error', (err) => finish(null, null, err));
proc.once('close', (code, signal) => finish(code, signal));
});
this.#log.info(
`video recording started (${w}x${h} @ ${f.toFixed(3)} fps` +
`${f < requestedFps ? `, limited from ${requestedFps} fps by ${plan.shutterUs}µs shutter` : ''}` +
`${durationSec ? `, ${durationSec}s` : ''})`,
);
return this.#jobSummary();
}
async #finishVideo(job, rawFile, base, fps) {
let finalFile = rawFile;
if (this.#cfg.remuxMp4 && this.#hasFfmpeg()) {
const mp4 = path.join(this.#dirs.videos, `${base}.mp4`);
const code = await new Promise((resolve) => {
const proc = spawn(
this.#cfg.ffmpegBin,
['-y', '-loglevel', 'error', '-framerate', String(fps), '-i', rawFile, '-c', 'copy', mp4],
{ stdio: 'ignore' },
);
proc.on('error', () => resolve(1));
proc.on('close', resolve);
});
if (code === 0 && fs.existsSync(mp4)) {
await fsp.unlink(rawFile).catch(() => {});
finalFile = mp4;
} else {
this.#log.warn('mp4 remux failed; keeping raw .h264');
}
}
const stat = await fsp.stat(finalFile);
const info = {
type: 'video',
name: path.basename(finalFile),
url: `/media/videos/${path.basename(finalFile)}`,
bytes: stat.size,
durationSec: Math.round((Date.now() - job.startedAt) / 100) / 10,
takenAt: new Date(job.startedAt).toISOString(),
meta: this.#meta(),
};
this.#lastCapture = { type: 'video', url: info.url, at: new Date().toISOString() };
this.#log.info(`video saved: ${info.name} (${(info.bytes / 1e6).toFixed(1)} MB, ${info.durationSec}s)`);
this.emit('capture', info);
return info;
}
#hasFfmpeg() {
if (this.#ffmpegAvailable === null) {
this.#ffmpegAvailable = !spawnSync(this.#cfg.ffmpegBin, ['-version'], { stdio: 'ignore' }).error;
if (!this.#ffmpegAvailable) this.#log.warn('ffmpeg not found; videos stay as raw .h264 (apt install ffmpeg to get .mp4)');
}
return this.#ffmpegAvailable;
}
async stopVideo() {
const job = this.#job;
if (!job || job.type !== 'video') {
throw new ServiceError('not_recording', 'No video recording in progress', 409);
}
if (!job.stopping) {
job.stopping = true;
job.proc.kill('SIGINT');
const killer = setTimeout(() => job.proc.kill('SIGKILL'), 5000);
job.done.finally(() => clearTimeout(killer));
}
return job.done;
}
// ───────────────────────────── timelapse ─────────────────────────────
async startTimelapse(opts = {}) {
const { intervalSec, count = 0, durationSec = 0, name, raw = false, ...settingOverrides } = opts;
if (!(Number(intervalSec) >= 1)) {
throw new ServiceError('invalid_option', '"intervalSec" must be a number >= 1', 400);
}
if (!(Number(count) >= 0) || !(Number(durationSec) >= 0)) {
throw new ServiceError('invalid_option', '"count" and "durationSec" must be >= 0', 400);
}
if (!count && !durationSec) {
throw new ServiceError('invalid_option', 'Provide "count" and/or "durationSec" (use stopTimelapse to end early)', 400);
}
const settings = { ...this.#settings, ...normalizeSettings(settingOverrides) };
if (settings.shutterUs / 1e6 >= intervalSec) {
throw new ServiceError(
'invalid_option',
`intervalSec (${intervalSec}) must be longer than the exposure (${(settings.shutterUs / 1e6).toFixed(2)}s)`,
400,
);
}
await this.#acquire('timelapse');
const id = `${String(name || 'timelapse').replace(/[^\w-]+/g, '_')}_${stamp()}`;
const dir = path.join(this.#dirs.timelapse, id);
try {
await fsp.mkdir(dir, { recursive: true });
} catch (err) {
this.#release();
throw new ServiceError('storage_error', `Cannot create ${dir}: ${err.message}`, 500);
}
const sleeper = sleepable();
const job = {
type: 'timelapse',
id,
dir,
intervalSec: Number(intervalSec),
total: Number(count) || null,
endAt: durationSec ? Date.now() + durationSec * 1000 : null,
startedAt: Date.now(),
frames: 0,
failures: 0,
stop: false,
wake: sleeper.wake,
sleeper,
nextAt: null,
done: null,
};
this.#job = job;
job.done = this.#timelapseLoop(job, settings, raw)
.catch((err) => this.#log.error(`timelapse aborted: ${err.message}`))
.then(() => {
const summary = {
type: 'timelapse',
id,
frames: job.frames,
dir: `/media/timelapse/${id}`,
stoppedByUser: job.stop,
};
this.#lastCapture = { type: 'timelapse', url: summary.dir, at: new Date().toISOString() };
this.#log.info(`timelapse finished: ${job.frames} frames in ${id}`);
this.emit('timelapse', { ...summary, state: 'finished' });
this.emit('capture', summary);
this.#release();
return summary;
});
await fsp.writeFile(
path.join(dir, 'session.json'),
JSON.stringify({
id,
startedAt: new Date().toISOString(),
intervalSec,
count,
durationSec,
settings,
meta: this.#meta(),
}, null, 2),
).catch(() => {});
this.#log.info(
`timelapse started: every ${intervalSec}s${count ? `, ${count} frames` : ''}${durationSec ? `, ${durationSec}s` : ''} -> ${id}`,
);
return this.#jobSummary();
}
async #timelapseLoop(job, settings, raw) {
const frameLog = path.join(job.dir, 'frames.jsonl');
while (!job.stop) {
const tickStart = Date.now();
const n = job.frames + 1;
const file = path.join(job.dir, `frame_${String(n).padStart(5, '0')}.jpg`);
try {
const meta = this.#meta();
const result = await this.#captureStill(file, settings, { raw });
if (job.stop && !fs.existsSync(file)) break;
job.frames = n;
job.failures = 0;
await fsp.appendFile(
frameLog,
JSON.stringify({
n,
file: path.basename(file),
at: new Date().toISOString(),
bytes: result.bytes,
meta,
}) + '\n',
).catch(() => {});
this.emit('timelapse', { id: job.id, state: 'frame', frames: job.frames, total: job.total });
} catch (err) {
if (job.stop) break;
job.failures += 1;
this.#log.error(`timelapse frame ${n} failed (${job.failures}/3): ${err.message}`);
if (job.failures >= 3) throw new Error('3 consecutive frame failures');
}
if (job.total && job.frames >= job.total) break;
const nextAt = tickStart + job.intervalSec * 1000;
if (job.endAt && nextAt > job.endAt) break;
const wait = nextAt - Date.now();
job.nextAt = nextAt;
if (wait > 0) {
await Promise.race([
new Promise((resolve) => setTimeout(resolve, wait)),
job.sleeper.promise,
]);
} else {
this.#log.warn(`timelapse frame took longer than the ${job.intervalSec}s interval; not waiting`);
}
}
}
async stopTimelapse() {
const job = this.#job;
if (!job || job.type !== 'timelapse') {
throw new ServiceError('no_timelapse', 'No timelapse in progress', 409);
}
job.stop = true;
job.wake();
this.#activeProc?.kill('SIGTERM');
return job.done;
}
// ───────────────────────────── file listing ─────────────────────────────
async listFiles({ limit = 50 } = {}) {
const read = async (dir, url) => {
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
const out = [];
for (const entry of entries) {
if (entry.name.endsWith('.json') || entry.name.endsWith('.jsonl')) continue;
const fullPath = path.join(dir, entry.name);
const stat = await fsp.stat(fullPath).catch(() => null);
if (!stat) continue;
if (entry.isDirectory()) {
const frames = (await fsp.readdir(fullPath).catch(() => [])).filter((name) => name.endsWith('.jpg')).length;
out.push({ name: entry.name, url: `${url}/${entry.name}`, frames, mtime: stat.mtime });
} else {
out.push({ name: entry.name, url: `${url}/${entry.name}`, bytes: stat.size, mtime: stat.mtime });
}
}
return out.sort((a, b) => b.mtime - a.mtime).slice(0, limit);
};
return {
photos: await read(this.#dirs.photos, '/media/photos'),
videos: await read(this.#dirs.videos, '/media/videos'),
timelapses: await read(this.#dirs.timelapse, '/media/timelapse'),
};
}
// ───────────────────────────── rpicam argument builder ─────────────────────────────
#controlArgs(settings, { shutterUs = settings.shutterUs } = {}) {
const args = [];
if (shutterUs > 0) args.push('--shutter', String(Math.round(shutterUs)));
if (settings.gain > 0) args.push('--gain', String(settings.gain));
if (settings.ev) args.push('--ev', String(settings.ev));
if (settings.exposureMode !== 'normal') args.push('--exposure', settings.exposureMode);
args.push(
'--brightness', String(settings.brightness),
'--contrast', String(settings.contrast),
'--saturation', String(settings.saturation),
'--sharpness', String(settings.sharpness),
);
if (settings.awbGains) args.push('--awbgains', settings.awbGains.join(','));
else args.push('--awb', settings.awb);
args.push('--denoise', settings.denoise);
if (settings.hflip) args.push('--hflip');
if (settings.vflip) args.push('--vflip');
if (settings.rotation) args.push('--rotation', String(settings.rotation));
if (settings.roi) args.push('--roi', settings.roi.join(','));
args.push(
'--autofocus-mode', settings.autofocusMode,
'--autofocus-range', settings.autofocusRange,
'--autofocus-speed', settings.autofocusSpeed,
);
if (settings.autofocusMode === 'manual' && settings.lensPosition !== null) {
args.push('--lens-position', String(settings.lensPosition));
}
return args;
}
#jobSummary() {
const job = this.#job;
if (!job) return null;
if (job.type === 'video') {
return {
type: 'video',
id: job.id,
elapsedSec: Math.round((Date.now() - job.startedAt) / 100) / 10,
durationSec: job.durationSec || null,
stopping: job.stopping,
};
}
return {
type: 'timelapse',
id: job.id,
frames: job.frames,
total: job.total,
intervalSec: job.intervalSec,
elapsedSec: Math.round((Date.now() - job.startedAt) / 1000),
nextFrameAt: job.nextAt ? new Date(job.nextAt).toISOString() : null,
dir: `/media/timelapse/${job.id}`,
};
}
}