diff --git a/data/camera-settings.json b/data/camera-settings.json new file mode 100644 index 0000000..b424610 --- /dev/null +++ b/data/camera-settings.json @@ -0,0 +1,22 @@ +{ + "shutterUs": 0, + "gain": 0, + "ev": -0.04, + "exposureMode": "long", + "brightness": 0, + "contrast": 1, + "saturation": 1, + "sharpness": 1, + "awb": "fluorescent", + "awbGains": null, + "denoise": "off", + "autofocusMode": "manual", + "autofocusRange": "macro", + "autofocusSpeed": "normal", + "lensPosition": 4.8, + "hflip": false, + "vflip": false, + "rotation": 0, + "quality": 93, + "roi": null +} \ No newline at end of file diff --git a/data/captures/photos/photo_20261022T003029Z.jpg b/data/captures/photos/photo_20261022T003029Z.jpg new file mode 100644 index 0000000..cecff2d Binary files /dev/null and b/data/captures/photos/photo_20261022T003029Z.jpg differ diff --git a/data/captures/photos/photo_20261022T003029Z.json b/data/captures/photos/photo_20261022T003029Z.json new file mode 100644 index 0000000..de8f809 --- /dev/null +++ b/data/captures/photos/photo_20261022T003029Z.json @@ -0,0 +1,86 @@ +{ + "type": "photo", + "name": "photo_20261022T003029Z.jpg", + "url": "/media/photos/photo_20261022T003029Z.jpg", + "raw": null, + "bytes": 2766941, + "durationMs": 4318, + "takenAt": "2026-10-22T00:30:33.571Z", + "settings": { + "shutterUs": 0, + "gain": 0, + "ev": 0, + "exposureMode": "normal", + "brightness": 0, + "contrast": 1, + "saturation": 1, + "sharpness": 1, + "awb": "auto", + "awbGains": null, + "denoise": "auto", + "autofocusMode": "manual", + "autofocusRange": "normal", + "autofocusSpeed": "normal", + "lensPosition": 0, + "hflip": false, + "vflip": false, + "rotation": 0, + "quality": 93, + "roi": null, + "iso": 0 + }, + "meta": { + "telemetry": { + "time": { + "iso": "2026-10-22T00:30:29.252Z", + "epochMs": 1792629029252 + }, + "sensor": { + "state": "backoff", + "sampleAgeMs": null, + "restarts": 6, + "lastError": "exited (code=1, signal=null)" + }, + "orientation": null, + "battery": { + "state": "ok", + "available": true, + "source": "pisugar-server", + "socketPath": "/tmp/pisugar-server.sock", + "voltageV": 3.874, + "percent": 85, + "percentEstimate": 85, + "percentIsEstimate": false, + "powerPlugged": true, + "chargingAllowed": true, + "charging": true, + "updatedAt": "2026-10-22T00:30:25.646Z", + "error": null + }, + "gps": { + "state": "no_fix", + "position": null, + "satellites": { + "used": 0, + "seen": 26 + }, + "dop": { + "hdop": null, + "vdop": null, + "pdop": null + }, + "speedMps": null, + "clockSkewMs": -609, + "errorM": { + "x": null, + "y": null, + "v": null + } + } + }, + "pointing": { + "available": false, + "error": "no_orientation" + } + } +} \ No newline at end of file diff --git a/data/captures/photos/photo_20261022T005834Z.jpg b/data/captures/photos/photo_20261022T005834Z.jpg new file mode 100644 index 0000000..0ccc5c3 Binary files /dev/null and b/data/captures/photos/photo_20261022T005834Z.jpg differ diff --git a/data/captures/photos/photo_20261022T005834Z.json b/data/captures/photos/photo_20261022T005834Z.json new file mode 100644 index 0000000..13662df --- /dev/null +++ b/data/captures/photos/photo_20261022T005834Z.json @@ -0,0 +1,86 @@ +{ + "type": "photo", + "name": "photo_20261022T005834Z.jpg", + "url": "/media/photos/photo_20261022T005834Z.jpg", + "raw": null, + "bytes": 4131974, + "durationMs": 3962, + "takenAt": "2026-10-22T00:58:38.129Z", + "settings": { + "shutterUs": 0, + "gain": 5, + "ev": 0, + "exposureMode": "long", + "brightness": 0, + "contrast": 1, + "saturation": 1, + "sharpness": 1, + "awb": "indoor", + "awbGains": null, + "denoise": "off", + "autofocusMode": "continuous", + "autofocusRange": "macro", + "autofocusSpeed": "normal", + "lensPosition": 0.4, + "hflip": false, + "vflip": false, + "rotation": 0, + "quality": 93, + "roi": null, + "iso": 500 + }, + "meta": { + "telemetry": { + "time": { + "iso": "2026-10-22T00:58:34.160Z", + "epochMs": 1792630714160 + }, + "sensor": { + "state": "backoff", + "sampleAgeMs": null, + "restarts": 11, + "lastError": "exited (code=1, signal=null)" + }, + "orientation": null, + "battery": { + "state": "ok", + "available": true, + "source": "pisugar-server", + "socketPath": "/tmp/pisugar-server.sock", + "voltageV": 3.893, + "percent": 87, + "percentEstimate": 87, + "percentIsEstimate": false, + "powerPlugged": true, + "chargingAllowed": true, + "charging": true, + "updatedAt": "2026-10-22T00:58:31.250Z", + "error": null + }, + "gps": { + "state": "no_fix", + "position": null, + "satellites": { + "used": 0, + "seen": 20 + }, + "dop": { + "hdop": null, + "vdop": null, + "pdop": null + }, + "speedMps": null, + "clockSkewMs": -80, + "errorM": { + "x": null, + "y": null, + "v": null + } + } + }, + "pointing": { + "available": false, + "error": "no_orientation" + } + } +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..ed1bb95 --- /dev/null +++ b/index.html @@ -0,0 +1,206 @@ + + + +MRS Telescope + +
+ live view +
+
Live View
no pointing
+ +
Field of view
+
+
+
+ +
+

Target

+
+
+
+

Mount & focuser hardware not connected

+
+
+
Focus–
+
Slew speed
+
Focus step
+

Orientation

+
+

Telemetry

+
+ +
+

Capture

+
+
+

Camera

+

Recommended now

+
+
+ diff --git a/server.mjs b/server.mjs index c84ab26..ae3632d 100644 --- a/server.mjs +++ b/server.mjs @@ -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 }); } diff --git a/services/sky.mjs b/services/sky.mjs new file mode 100644 index 0000000..259df5e --- /dev/null +++ b/services/sky.mjs @@ -0,0 +1,131 @@ +// Sky service: target info, "what should I look at" recommendations, and overlay objects. +// Positions come from CelestialService.predict() (astronomy-engine, fully offline for Sun/Moon/planets/stars). +import * as Astronomy from 'astronomy-engine'; +import { STARS } from '../lib/stars.mjs'; +import { angularSeparation } from '../lib/geo.mjs'; + +// Bundled deep-sky showpieces (J2000 RA hours, Dec deg, visual magnitude). Swap for OpenNGC later. +const DSO = [ + ['m31', 'M31 Andromeda Galaxy', 'Galaxy', 0.712, 41.27, 3.4], + ['m42', 'M42 Orion Nebula', 'Nebula', 5.588, -5.39, 4.0], + ['m45', 'M45 Pleiades', 'Open Cluster', 3.79, 24.12, 1.6], + ['m44', 'M44 Beehive', 'Open Cluster', 8.667, 19.67, 3.7], + ['m13', 'M13 Hercules Cluster', 'Globular', 16.695, 36.46, 5.8], + ['m8', 'M8 Lagoon Nebula', 'Nebula', 18.063, -24.38, 6.0], + ['m27', 'M27 Dumbbell Nebula', 'Planetary Nebula', 19.994, 22.72, 7.4], + ['m57', 'M57 Ring Nebula', 'Planetary Nebula', 18.893, 33.03, 8.8], + ['m81', 'M81 Bode\'s Galaxy', 'Galaxy', 9.926, 69.07, 6.9], + ['m51', 'M51 Whirlpool Galaxy', 'Galaxy', 13.498, 47.2, 8.4], +].map(([id, label, type, ra, dec, mag]) => ({ id, label, type, ra, dec, mag, spec: `radec:${ra},${dec}` })); + +const PLANETS = ['mercury', 'venus', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune']; +const OUTER = ['Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune']; +const r = (v, d = 1) => (v == null ? v : Math.round(v * 10 ** d) / 10 ** d); +const iso = (t) => t?.date?.toISOString?.() ?? null; +const cap = (s) => s[0].toUpperCase() + s.slice(1); + +export class SkyService { + #c; + #t; + + constructor({ celestial, telemetry }) { + this.#c = celestial; + this.#t = telemetry; + } + + #candidates() { + return [ + { id: 'body:moon', label: 'Moon', type: 'Moon', spec: 'moon' }, + ...PLANETS.map((p) => ({ id: `body:${p}`, label: cap(p), type: 'Planet', spec: `body:${p}` })), + ...Object.entries(STARS).map(([k, s]) => ({ id: `star:${k}`, label: s.label, type: 'Star', mag: s.mag ?? 2, spec: `star:${k}` })), + ...DSO.map((d) => ({ ...d, id: `dso:${d.id}` })), + ]; + } + + /** Detail for one target: position + (for solar-system bodies) phase, magnitude, elongation, opposition, rise/transit/set. */ + info(spec) { + const date = this.#t.now(); + const p = this.#c.predict(spec, { date }); + const out = { ...p }; + if (p.target.kind !== 'body') return out; + + const name = p.target.label; + const o = new Astronomy.Observer(p.observer.lat, p.observer.lon, p.observer.altM); + const ill = Astronomy.Illumination(name, date); + out.magnitude = r(ill.mag); + out.phasePct = r(ill.phase_fraction * 100, 0); + out.distanceKm = Math.round(p.distanceAu * 149_597_870.7); + out.elongationDeg = name === 'Sun' ? 0 : r(Astronomy.AngleFromSun(name, date)); + out.events = { + rise: iso(Astronomy.SearchRiseSet(name, o, +1, date, 2)), + transit: iso(Astronomy.SearchHourAngle(name, o, 0, date).time), + set: iso(Astronomy.SearchRiseSet(name, o, -1, date, 2)), + }; + if (OUTER.includes(name)) { + out.oppositionPct = r((out.elongationDeg / 180) * 100, 0); // 100% = exactly at opposition + out.nextOpposition = iso(Astronomy.SearchRelativeLongitude(name, 180, date)); + } + return out; + } + + /** Ranked list of what's worth observing right now. */ + recommend({ limit = 8, minAlt = 15 } = {}) { + const date = this.#t.now(); + const moon = this.#c.predict('moon', { date }); + const moonIllum = (1 - Math.cos((Astronomy.MoonPhase(date) * Math.PI) / 180)) / 2; + const rows = []; + + for (const c of this.#candidates()) { + let p; + try { + p = this.#c.predict(c.spec, { date }); + } catch { + continue; + } + if (p.alt < minAlt) continue; + + const row = { id: c.id, spec: c.spec, label: c.label, type: c.type, az: p.az, alt: p.alt, why: [] }; + let mag = c.mag; + let score = (Math.min(p.alt, 70) / 70) * 40; + + if (c.type === 'Planet' || c.type === 'Moon') { + const ill = Astronomy.Illumination(c.label, date); + mag = ill.mag; + row.phasePct = r(ill.phase_fraction * 100, 0); + const el = Astronomy.AngleFromSun(c.label, date); + if (OUTER.includes(c.label)) { + row.oppositionPct = r((el / 180) * 100, 0); + if (el > 150) { score += 15; row.why.push('near opposition'); } + } + if (el < 20 && c.type === 'Planet') { score -= 30; row.why.push('close to the Sun'); } + if (c.type === 'Planet') score += 10; + } + const moonSep = angularSeparation(p.az, p.alt, moon.az, moon.alt); + if (c.type !== 'Moon' && mag > 4 && moonSep < 40) { + score -= (40 - moonSep) * 0.6 * moonIllum; + row.why.push('washed out by Moon'); + } + score += Math.max(0, 10 - (mag ?? 10)) * 3; + if (p.alt > 50) row.why.push('high in sky'); + + row.mag = r(mag); + row.moonSepDeg = r(moonSep, 0); + row.score = r(score, 0); + rows.push(row); + } + return rows.sort((a, b) => b.score - a.score).slice(0, limit); + } + + /** Everything above (roughly) the horizon, for the sky-map / live overlay. */ + objects() { + const date = this.#t.now(); + const out = []; + for (const c of this.#candidates()) { + try { + const p = this.#c.predict(c.spec, { date }); + if (p.alt > -5) out.push({ id: c.id, spec: c.spec, label: c.label, type: c.type, mag: c.mag ?? null, az: p.az, alt: p.alt }); + } catch { /* skip */ } + } + return { time: date.toISOString(), objects: out }; + } +}