// 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}`, }; } }