generated from ztimson/template
init
This commit is contained in:
+287
@@ -0,0 +1,287 @@
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user