Files
eye-spy/server.mjs
T
2026-10-21 22:17:35 -04:00

417 lines
13 KiB
JavaScript

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;
});