generated from ztimson/template
New UI
This commit is contained in:
+157
-28
@@ -1,15 +1,20 @@
|
||||
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,
|
||||
@@ -43,9 +48,9 @@ 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),
|
||||
width: Number(process.env.W || 720),
|
||||
height: Number(process.env.H || 720),
|
||||
fps: Number(process.env.FPS || 30),
|
||||
fps: Number(process.env.FPS || 60),
|
||||
streamQuality: 85,
|
||||
streamMaxShutterUs: 1_000_000,
|
||||
streamIdleStopMs: 5000,
|
||||
@@ -81,6 +86,7 @@ if (process.env.DECLINATION_DEG !== undefined) {
|
||||
|
||||
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,
|
||||
@@ -106,6 +112,38 @@ function sendJson(res, status, data) {
|
||||
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 = '';
|
||||
@@ -134,8 +172,35 @@ function readJson(req) {
|
||||
|
||||
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;
|
||||
|
||||
if (req.method === 'GET' && url.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',
|
||||
@@ -144,34 +209,29 @@ async function handle(req, res) {
|
||||
'/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',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/stream') {
|
||||
// ───────────── stream / telemetry ─────────────
|
||||
|
||||
if (is('GET', '/stream')) {
|
||||
return camera.addStreamClient(req, res);
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/telemetry/battery') {
|
||||
if (is('GET', '/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') {
|
||||
if (is('GET', '/api/status')) {
|
||||
let celestialStatus;
|
||||
|
||||
try {
|
||||
@@ -189,7 +249,53 @@ async function handle(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/celestial/predict') {
|
||||
// ───────────── 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, {
|
||||
@@ -200,7 +306,7 @@ async function handle(req, res) {
|
||||
return sendJson(res, 200, celestial.predict(target));
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/celestial/delta') {
|
||||
if (is('GET', '/api/celestial/delta')) {
|
||||
const target = url.searchParams.get('target');
|
||||
if (!target) {
|
||||
return sendJson(res, 400, {
|
||||
@@ -211,18 +317,18 @@ async function handle(req, res) {
|
||||
return sendJson(res, 200, celestial.delta(target));
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/celestial/targets') {
|
||||
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 (req.method === 'GET' && url.pathname === '/api/celestial/alignment') {
|
||||
if (is('GET', '/api/celestial/alignment')) {
|
||||
return sendJson(res, 200, celestial.getAlignment());
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/celestial/zero') {
|
||||
if (is('POST', '/api/celestial/zero')) {
|
||||
const body = await readJson(req);
|
||||
if (!body.target) {
|
||||
return sendJson(res, 400, {
|
||||
@@ -233,15 +339,38 @@ async function handle(req, res) {
|
||||
return sendJson(res, 200, await celestial.zero(body.target));
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && url.pathname === '/api/celestial/zero') {
|
||||
if (is('DELETE', '/api/celestial/zero')) {
|
||||
return sendJson(res, 200, await celestial.clearZero());
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/celestial/tle/refresh') {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user