import { createServer } from 'node:http'; 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'; 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'; 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 || 1280), height: Number(process.env.H || 720), fps: Number(process.env.FPS || 30), 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 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)); } 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'}`); if (req.method === 'GET' && url.pathname === '/') { return sendJson(res, 200, { name: 'MRS', status: 'online', endpoints: [ '/stream', '/api/status', '/api/telemetry/battery', '/api/camera/settings', ], }); } if (req.method === 'GET' && url.pathname === '/stream') { return camera.addStreamClient(req, res); } if (req.method === 'GET' && url.pathname === '/api/telemetry/battery') { return sendJson(res, 200, { battery: telemetry.getBattery() }); } if (req.method === 'GET' && url.pathname === '/api/camera/settings') { return sendJson(res, 200, { settings: camera.getSettings() }); } if (req.method === 'PATCH' && url.pathname === '/api/camera/settings') { const body = await readJson(req); const settings = await camera.applySettings(body); return sendJson(res, 200, { ok: true, settings }); } if (req.method === 'POST' && url.pathname === '/api/camera/settings/reset') { const settings = await camera.resetSettings(); return sendJson(res, 200, { ok: true, settings }); } if (req.method === 'GET' && url.pathname === '/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(), }); } if (req.method === 'GET' && url.pathname === '/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 (req.method === 'GET' && url.pathname === '/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 (req.method === 'GET' && url.pathname === '/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 (req.method === 'GET' && url.pathname === '/api/celestial/alignment') { return sendJson(res, 200, celestial.getAlignment()); } if (req.method === 'POST' && url.pathname === '/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 (req.method === 'DELETE' && url.pathname === '/api/celestial/zero') { return sendJson(res, 200, await celestial.clearZero()); } if (req.method === 'POST' && url.pathname === '/api/celestial/tle/refresh') { const body = await readJson(req); return sendJson(res, 200, { results: await celestial.refreshTLEs({ force: body.force !== false }) }); } 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; });