import { createServer } from 'node:http'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { TelemetryService } from './services/telemetry.mjs'; import { CameraService } from './services/camera.mjs'; import { CelestialService } from './services/celestial.mjs'; import { SkyService } from './services/sky.mjs'; const ROOT = path.dirname(fileURLToPath(import.meta.url)); const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'data')); const PORT = Number(process.env.PORT || 8080); const HOST = process.env.HOST || '0.0.0.0'; // UI lives in the project root. Override with UI_FILE if you keep it elsewhere (e.g. ui/index.html). const UI_FILE = path.resolve(process.env.UI_FILE || path.join(ROOT, 'index.html')); const log = Object.fromEntries(['debug', 'info', 'warn', 'error'].map((level) => [ level, (...args) => console[level === 'debug' ? 'log' : level]( `[${new Date().toISOString()}] [${level}]`, ...args, ), ])); const telemetryConfig = { python: process.env.PYTHON || 'python3', script: path.resolve(process.env.MPU_SCRIPT || path.join(ROOT, 'mpu.py')), bnoAddress: 0x4b, bnoHz: 20, staleMs: 3000, restartAfterMs: 10000, startTimeoutMs: 10000, gpsdHost: process.env.GPSD_HOST || '127.0.0.1', gpsdPort: Number(process.env.GPSD_PORT || 2947), gpsStaleMs: 5000, pisugar: { enabled: process.env.PISUGAR_ENABLED !== 'false', i2cBus: Number(process.env.PISUGAR_I2C_BUS || 1), i2cAddress: Number(process.env.PISUGAR_I2C_ADDRESS || 0x75), pollMs: Number(process.env.PISUGAR_POLL_MS || 5000), i2cgetBin: process.env.I2CGET || 'i2cget', }, }; const cameraConfig = { vidBin: process.env.RPICAM_VID || 'rpicam-vid', stillBin: process.env.RPICAM_STILL || 'rpicam-still', ffmpegBin: process.env.FFMPEG || 'ffmpeg', width: Number(process.env.W || 720), height: Number(process.env.H || 720), fps: Number(process.env.FPS || 60), streamQuality: 85, streamMaxShutterUs: 1_000_000, streamIdleStopMs: 5000, photoWidth: 0, photoHeight: 0, videoWidth: 1280, videoHeight: 720, videoFps: 30, focusMaxSteps: 1000, remuxMp4: true, }; const fallbackLat = process.env.FALLBACK_LAT === undefined ? null : Number(process.env.FALLBACK_LAT); const fallbackLon = process.env.FALLBACK_LON === undefined ? null : Number(process.env.FALLBACK_LON); const celestialConfig = { tleBaseUrl: 'https://celestrak.org/NORAD/elements/gp.php', tleGroups: (process.env.TLE_GROUPS || 'visual,stations').split(',').map((s) => s.trim()).filter(Boolean), tleMaxAgeHours: 12, tleRetryMinutes: 360, maxOrientationAgeMs: 1500, onTargetDeg: 1, invertQuat: false, defaultBoresight: [0, 0, 1], ...(fallbackLat !== null && fallbackLon !== null && Number.isFinite(fallbackLat) && Number.isFinite(fallbackLon) ? { fallbackObserver: { lat: fallbackLat, lon: fallbackLon, altM: Number(process.env.FALLBACK_ALT || 0) } } : {}), }; if (process.env.DECLINATION_DEG !== undefined) { celestialConfig.declinationDeg = Number(process.env.DECLINATION_DEG); } const telemetry = new TelemetryService(telemetryConfig, { log }); const celestial = new CelestialService(celestialConfig, { telemetry, log, dataDir: DATA_DIR }); const sky = new SkyService({ celestial, telemetry }); const camera = new CameraService(cameraConfig, { log, dataDir: DATA_DIR, metaProvider: () => ({ telemetry: telemetry.getSnapshot(), pointing: safePointing(), }), }); function safePointing() { try { return celestial.getPointing(); } catch (err) { return { available: false, error: err.code || err.message }; } } function sendJson(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', }); res.end(JSON.stringify(data)); } const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.mp4': 'video/mp4', '.h264': 'video/h264', '.dng': 'image/x-adobe-dng', }; async function serveFile(res, file, cache = 'no-cache') { let st; try { st = await fsp.stat(file); if (!st.isFile()) throw new Error('not a file'); } catch { return sendJson(res, 404, { error: 'not_found' }); } res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Content-Length': st.size, 'Cache-Control': cache, }); fs.createReadStream(file) .on('error', (err) => res.destroy(err)) .pipe(res); } function readJson(req) { return new Promise((resolve, reject) => { let body = ''; req.setEncoding('utf8'); req.on('data', (chunk) => { body += chunk; if (body.length > 1_000_000) { reject(Object.assign(new Error('Request body too large'), { status: 413, code: 'body_too_large' })); req.destroy(); } }); req.on('end', () => { if (!body.trim()) return resolve({}); try { resolve(JSON.parse(body)); } catch { reject(Object.assign(new Error('Invalid JSON body'), { status: 400, code: 'invalid_json' })); } }); req.on('error', reject); }); } async function handle(req, res) { const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const is = (method, pathname) => req.method === method && url.pathname === pathname; // ───────────── UI + static ───────────── if (is('GET', '/')) { res.writeHead(302, { Location: '/index.html', 'Cache-Control': 'no-store' }); return res.end(); } if (is('GET', '/index.html')) { return serveFile(res, UI_FILE); } if (req.method === 'GET' && url.pathname.startsWith('/media/')) { let rel; try { rel = decodeURIComponent(url.pathname.slice('/media/'.length)); } catch { return sendJson(res, 400, { error: 'bad_path' }); } const base = path.resolve(camera.mediaRoot); const file = path.resolve(base, rel); if (!file.startsWith(base + path.sep)) return sendJson(res, 403, { error: 'forbidden' }); return serveFile(res, file, 'public, max-age=3600'); } // ───────────── API index ───────────── if (is('GET', '/api')) { return sendJson(res, 200, { name: 'MRS', status: 'online', endpoints: [ '/stream', '/api/status', '/api/telemetry/battery', '/api/camera/settings', '/api/camera/files', '/api/celestial/predict?target=', '/api/celestial/delta?target=', '/api/celestial/targets', '/api/celestial/alignment', '/api/sky/info?target=', '/api/sky/recommend', '/api/sky/objects', ], }); } // ───────────── stream / telemetry ───────────── if (is('GET', '/stream')) { return camera.addStreamClient(req, res); } if (is('GET', '/api/telemetry/battery')) { return sendJson(res, 200, { battery: telemetry.getBattery() }); } if (is('GET', '/api/status')) { let celestialStatus; try { celestialStatus = celestial.getStatus(); } catch (err) { celestialStatus = { error: err.code || 'unavailable', message: err.message }; } return sendJson(res, 200, { status: 'online', telemetry: telemetry.getSnapshot(), camera: camera.getStatus(), celestial: celestialStatus, pointing: safePointing(), }); } // ───────────── camera settings ───────────── if (is('GET', '/api/camera/settings')) { return sendJson(res, 200, { settings: camera.getSettings() }); } if (is('PATCH', '/api/camera/settings')) { const body = await readJson(req); const settings = await camera.applySettings(body); return sendJson(res, 200, { ok: true, settings }); } if (is('POST', '/api/camera/settings/reset')) { const settings = await camera.resetSettings(); return sendJson(res, 200, { ok: true, settings }); } // ───────────── camera capture jobs ───────────── if (is('POST', '/api/camera/photo')) { return sendJson(res, 200, await camera.takePhoto(await readJson(req))); } if (is('POST', '/api/camera/video/start')) { return sendJson(res, 200, { ok: true, job: await camera.startVideo(await readJson(req)) }); } if (is('POST', '/api/camera/video/stop')) { return sendJson(res, 200, await camera.stopVideo()); } if (is('POST', '/api/camera/timelapse/start')) { return sendJson(res, 200, { ok: true, job: await camera.startTimelapse(await readJson(req)) }); } if (is('POST', '/api/camera/timelapse/stop')) { return sendJson(res, 200, await camera.stopTimelapse()); } if (is('GET', '/api/camera/files')) { const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 50))); return sendJson(res, 200, await camera.listFiles({ limit })); } // ───────────── celestial ───────────── if (is('GET', '/api/celestial/predict')) { const target = url.searchParams.get('target'); if (!target) { return sendJson(res, 400, { error: 'missing_target', message: 'Pass ?target=moon, vega, iss, sat:25544, etc.', }); } return sendJson(res, 200, celestial.predict(target)); } if (is('GET', '/api/celestial/delta')) { const target = url.searchParams.get('target'); if (!target) { return sendJson(res, 400, { error: 'missing_target', message: 'Pass ?target=moon, vega, iss, sat:25544, etc.', }); } return sendJson(res, 200, celestial.delta(target)); } if (is('GET', '/api/celestial/targets')) { const kind = url.searchParams.get('kind') || undefined; const q = url.searchParams.get('q') || undefined; const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 100))); return sendJson(res, 200, { targets: celestial.listTargets({ kind, q, limit }) }); } if (is('GET', '/api/celestial/alignment')) { return sendJson(res, 200, celestial.getAlignment()); } if (is('POST', '/api/celestial/zero')) { const body = await readJson(req); if (!body.target) { return sendJson(res, 400, { error: 'missing_target', message: 'Provide JSON {"target":"moon"}.', }); } return sendJson(res, 200, await celestial.zero(body.target)); } if (is('DELETE', '/api/celestial/zero')) { return sendJson(res, 200, await celestial.clearZero()); } if (is('POST', '/api/celestial/tle/refresh')) { const body = await readJson(req); return sendJson(res, 200, { results: await celestial.refreshTLEs({ force: body.force !== false }) }); } // ───────────── sky (info / recommendations / overlay) ───────────── if (is('GET', '/api/sky/info')) { const target = url.searchParams.get('target'); if (!target) { return sendJson(res, 400, { error: 'missing_target', message: 'Pass ?target=moon, saturn, vega, iss, radec:5.9,7.4, etc.', }); } return sendJson(res, 200, sky.info(target)); } if (is('GET', '/api/sky/recommend')) { const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') || 8))); const minAlt = Number(url.searchParams.get('minAlt') || 15); return sendJson(res, 200, { recommendations: sky.recommend({ limit, minAlt }) }); } if (is('GET', '/api/sky/objects')) { return sendJson(res, 200, sky.objects()); } return sendJson(res, 404, { error: 'not_found', path: url.pathname }); } let server; let stopping = false; async function shutdown(signal) { if (stopping) return; stopping = true; log.info(`received ${signal}; shutting down`); if (server) { server.close(); server.closeAllConnections?.(); } await Promise.allSettled([camera.shutdown(), celestial.shutdown(), telemetry.stop()]); log.info('shutdown complete'); } async function main() { await camera.init(); telemetry.start(); await celestial.init(); server = createServer((req, res) => { Promise.resolve(handle(req, res)).catch((err) => { log.error(err.stack || err.message || err); if (res.headersSent) return res.destroy(err); sendJson(res, err.status || 500, { error: err.code || 'internal_error', message: err.message }); }); }); server.listen(PORT, HOST, () => log.info(`MRS listening at http://${HOST}:${PORT}`)); process.once('SIGINT', () => shutdown('SIGINT')); process.once('SIGTERM', () => shutdown('SIGTERM')); } main().catch(async (err) => { log.error(`startup failed: ${err.stack || err}`); await Promise.allSettled([camera.shutdown(), celestial.shutdown(), telemetry.stop()]); process.exitCode = 1; });