// Celestial service // // Targets (string "specs", usable from the HTTP API too): // sat:25544 sat:ISS (ZARYA) iss Earth satellites, from cached TLEs (Celestrak) + SGP4 // body:moon moon jupiter … Sun, Moon, planets (astronomy-engine ephemeris) // star:vega vega ~18 bright stars (J2000 → precessed to date) // radec:5.919,7.407 any fixed RA (hours) / Dec (degrees) J2000 position // // NOTE: TLEs only describe Earth-orbiting satellites. The Moon, planets, stars and deep-sky // objects are NOT in any TLE set, so they use ephemerides / catalogue coordinates instead. // // Public API // predict(spec, {date?, observer?}) where the target is (az/alt) for the GPS position + OS time // delta(spec, {date?, observer?}) how far the telescope's current pointing is from the target // zero(spec) align the IMU to the telescope using a known object you're centred on // getPointing() current telescope az/alt (IMU + alignment + declination) // refreshTLEs({force?}) download/cache TLEs // // How alignment works // The IMU sits in the eyepiece at an unknown angle, so a simple "+X° azimuth, +Y° altitude" offset // only holds near the pose it was measured at. Instead we solve for the telescope's boresight // direction in the *sensor's own frame*: b = q⁻¹ · d_target // (q = sensor orientation, d_target = predicted direction of the object you are centred on). // Afterwards pointing(t) = q(t) · b for ANY orientation, which stays correct however the sensor is // mounted. The BNO's frame is referenced to MAGNETIC north, so magnetic declination (from the World // Magnetic Model at the GPS position) is applied to get true azimuth before comparing with the sky. import { EventEmitter } from 'node:events'; import fsp from 'node:fs/promises'; import path from 'node:path'; import * as satellite from 'satellite.js'; import * as Astronomy from 'astronomy-engine'; import { ServiceError } from '../lib/errors.mjs'; import { STARS } from '../lib/stars.mjs'; import { azAltToVec, vecToAzAlt, quatConj, quatRotate, wrap360, wrap180, angularSeparation, rad, deg, } from '../lib/geo.mjs'; const BODIES = { sun: 'Sun', moon: 'Moon', mercury: 'Mercury', venus: 'Venus', mars: 'Mars', jupiter: 'Jupiter', saturn: 'Saturn', uranus: 'Uranus', neptune: 'Neptune', pluto: 'Pluto', }; const round = (v, d = 3) => (v === null || v === undefined ? v : Math.round(v * 10 ** d) / 10 ** d); function parseTle(text) { const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); const out = []; for (let i = 0; i < lines.length; ) { if (lines[i].startsWith('1 ') && lines[i + 1]?.startsWith('2 ')) { out.push({ name: `NORAD ${lines[i].slice(2, 7).trim()}`, l1: lines[i], l2: lines[i + 1] }); i += 2; } else if (lines[i + 1]?.startsWith('1 ') && lines[i + 2]?.startsWith('2 ')) { out.push({ name: lines[i].replace(/^0 /, ''), l1: lines[i + 1], l2: lines[i + 2] }); i += 3; } else { i += 1; } } return out; } function tleEpochMs(satrec) { const year = satrec.epochyr < 57 ? 2000 + satrec.epochyr : 1900 + satrec.epochyr; return Date.UTC(year, 0, 1) + (satrec.epochdays - 1) * 86_400_000; } export class CelestialService extends EventEmitter { #cfg; #log; #telemetry; #dir; #alignmentFile; #sats = new Map(); // noradId -> { id, name, satrec, epochMs, group } #groupInfo = new Map(); // group -> { count, fetchedAt } #alignment = null; #geomag = null; #refreshing = null; #timer = null; #lastRefreshError = null; constructor(cfg, { telemetry, log, dataDir }) { super(); this.#cfg = cfg; this.#telemetry = telemetry; this.#log = log; this.#dir = path.join(dataDir, 'tle'); this.#alignmentFile = path.join(dataDir, 'alignment.json'); } // ───────────────────────────── lifecycle ───────────────────────────── async init() { await fsp.mkdir(this.#dir, { recursive: true }); try { const mod = await import('geomagnetism'); this.#geomag = mod.default ?? mod; } catch { this.#log.warn('"geomagnetism" not installed: magnetic declination will use DECLINATION_DEG (default 0)'); } try { this.#alignment = JSON.parse(await fsp.readFile(this.#alignmentFile, 'utf8')); this.#log.info(`loaded saved alignment (zeroed on ${this.#alignment.target?.label} at ${this.#alignment.createdAt})`); } catch (err) { if (err.code !== 'ENOENT') this.#log.warn(`ignoring unreadable alignment file: ${err.message}`); } await this.#loadCache(); // Refresh in the background: never block startup on the network (we may be in a field with no internet). this.refreshTLEs().catch(() => {}); this.#timer = setInterval(() => this.refreshTLEs().catch(() => {}), this.#cfg.tleRetryMinutes * 60_000); this.#timer.unref(); } shutdown() { clearInterval(this.#timer); } // ───────────────────────────── TLE cache ───────────────────────────── async #loadCache() { for (const group of this.#cfg.tleGroups) { const file = path.join(this.#dir, `${group}.tle`); try { const [text, st] = await Promise.all([fsp.readFile(file, 'utf8'), fsp.stat(file)]); this.#groupInfo.set(group, { count: parseTle(text).length, fetchedAt: st.mtimeMs, text }); } catch (err) { if (err.code !== 'ENOENT') this.#log.warn(`TLE cache for "${group}" unreadable: ${err.message}`); } } this.#rebuildIndex(); } #rebuildIndex() { const sats = new Map(); for (const [group, info] of this.#groupInfo) { for (const t of parseTle(info.text)) { let satrec; try { satrec = satellite.twoline2satrec(t.l1, t.l2); } catch { continue; } const id = parseInt(t.l1.slice(2, 7), 10); if (!Number.isFinite(id) || !Number.isFinite(satrec.no)) continue; const epochMs = tleEpochMs(satrec); const prev = sats.get(id); if (!prev || epochMs > prev.epochMs) sats.set(id, { id, name: t.name, satrec, epochMs, group }); } } this.#sats = sats; } /** * Download any TLE group that is missing/older than TLE_MAX_AGE_HOURS (or all, with force). * Failures are non-fatal: cached data keeps being served. */ async refreshTLEs({ force = false } = {}) { if (this.#refreshing) return this.#refreshing; this.#refreshing = (async () => { const results = []; const maxAgeMs = this.#cfg.tleMaxAgeHours * 3_600_000; for (const group of this.#cfg.tleGroups) { const info = this.#groupInfo.get(group); if (!force && info && Date.now() - info.fetchedAt < maxAgeMs) { results.push({ group, skipped: 'fresh', count: info.count }); continue; } try { const text = await this.#fetchGroup(group); const count = parseTle(text).length; const file = path.join(this.#dir, `${group}.tle`); await fsp.writeFile(`${file}.tmp`, text); await fsp.rename(`${file}.tmp`, file); // atomic: never leave a half-written cache this.#groupInfo.set(group, { count, fetchedAt: Date.now(), text }); this.#log.info(`TLE group "${group}" updated (${count} objects)`); results.push({ group, ok: true, count }); } catch (err) { this.#lastRefreshError = { at: new Date().toISOString(), message: err.message }; this.#log.warn(`TLE refresh "${group}" failed: ${err.message}${info ? ' — using cached copy' : ''}`); results.push({ group, ok: false, error: err.message, usingCache: Boolean(info) }); } } this.#rebuildIndex(); this.emit('tle', this.getTleStatus()); return results; })().finally(() => { this.#refreshing = null; }); return this.#refreshing; } async #fetchGroup(group) { const url = `${this.#cfg.tleBaseUrl}?GROUP=${encodeURIComponent(group)}&FORMAT=tle`; const res = await fetch(url, { signal: AbortSignal.timeout(20_000), headers: { 'User-Agent': 'eye-spy/1.0' } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); if (parseTle(text).length === 0) throw new Error('response contained no TLEs'); return text; } getTleStatus() { const groups = {}; for (const [g, i] of this.#groupInfo) { groups[g] = { count: i.count, fetchedAt: new Date(i.fetchedAt).toISOString(), ageHours: round((Date.now() - i.fetchedAt) / 3_600_000, 1) }; } const epochs = [...this.#sats.values()].map((s) => s.epochMs); return { satellites: this.#sats.size, groups, wantedGroups: this.#cfg.tleGroups, oldestEpoch: epochs.length ? new Date(Math.min(...epochs)).toISOString() : null, refreshing: Boolean(this.#refreshing), lastError: this.#lastRefreshError, }; } // ───────────────────────────── targets ───────────────────────────── listTargets({ kind, q, limit = 100 } = {}) { const query = q?.toLowerCase(); const match = (t) => !query || t.id.toLowerCase().includes(query) || t.label.toLowerCase().includes(query); const all = []; if (!kind || kind === 'body') { for (const [k, v] of Object.entries(BODIES)) all.push({ kind: 'body', id: `body:${k}`, label: v }); } if (!kind || kind === 'star') { for (const [k, v] of Object.entries(STARS)) all.push({ kind: 'star', id: `star:${k}`, label: v.label }); } if (!kind || kind === 'sat') { for (const s of this.#sats.values()) all.push({ kind: 'sat', id: `sat:${s.id}`, label: s.name }); } return all.filter(match).slice(0, limit); } /** Resolve a target spec string into a normalised target object. */ resolveTarget(spec) { const raw = String(spec ?? '').trim(); if (!raw) throw new ServiceError('invalid_target', 'No target given (e.g. "moon", "vega", "iss", "sat:25544", "radec:5.9,7.4")', 400); let kind; let rest; const m = raw.match(/^(sat|body|star|radec):(.*)$/i); if (m) { kind = m[1].toLowerCase(); rest = m[2].trim(); } else { const key = raw.toLowerCase(); if (key === 'iss') [kind, rest] = ['sat', '25544']; else if (BODIES[key]) [kind, rest] = ['body', key]; else if (STARS[key]) [kind, rest] = ['star', key]; else [kind, rest] = ['sat', raw]; } if (kind === 'body') { const key = rest.toLowerCase(); if (!BODIES[key]) throw new ServiceError('unknown_target', `Unknown body "${rest}"`, 404, { valid: Object.keys(BODIES) }); return { kind, id: `body:${key}`, label: BODIES[key], body: BODIES[key] }; } if (kind === 'star') { const key = rest.toLowerCase(); const st = STARS[key]; if (!st) throw new ServiceError('unknown_target', `Unknown star "${rest}"`, 404, { valid: Object.keys(STARS) }); return { kind, id: `star:${key}`, label: st.label, ra: st.ra, dec: st.dec }; } if (kind === 'radec') { const [ra, dec] = rest.split(',').map(Number); if (!Number.isFinite(ra) || !Number.isFinite(dec) || ra < 0 || ra >= 24 || dec < -90 || dec > 90) { throw new ServiceError('invalid_target', 'radec must be "radec:," (J2000)', 400); } return { kind, id: `radec:${ra},${dec}`, label: `RA ${ra}h Dec ${dec}°`, ra, dec }; } // satellite if (this.#sats.size === 0) { throw new ServiceError('no_tle_data', 'No TLE data loaded yet (offline on first run?). Try POST /api/celestial/tle/refresh once online.', 503); } const asId = /^\d+$/.test(rest) ? parseInt(rest, 10) : null; if (asId !== null) { const s = this.#sats.get(asId); if (!s) throw new ServiceError('unknown_target', `NORAD ${asId} not in the loaded TLE groups (${this.#cfg.tleGroups.join(', ')})`, 404); return { kind: 'sat', id: `sat:${s.id}`, label: s.name, norad: s.id }; } const q = rest.toLowerCase(); const all = [...this.#sats.values()]; const exact = all.filter((s) => s.name.toLowerCase() === q); const starts = all.filter((s) => s.name.toLowerCase().startsWith(q)); const includes = all.filter((s) => s.name.toLowerCase().includes(q)); const hits = exact.length ? exact : starts.length ? starts : includes; if (hits.length === 0) throw new ServiceError('unknown_target', `No satellite matching "${rest}"`, 404); if (hits.length > 1 && !exact.length) { throw new ServiceError('ambiguous_target', `"${rest}" matches ${hits.length} satellites; use a NORAD id`, 400, { matches: hits.slice(0, 10).map((s) => ({ id: `sat:${s.id}`, name: s.name })), }); } return { kind: 'sat', id: `sat:${hits[0].id}`, label: hits[0].name, norad: hits[0].id }; } // ───────────────────────────── observer / time ───────────────────────────── #observer(override) { if (override) { const { lat, lon } = override; if (!Number.isFinite(lat) || !Number.isFinite(lon)) throw new ServiceError('invalid_observer', 'observer needs numeric lat and lon', 400); return { lat, lon, altM: override.altM ?? 0, source: 'override' }; } const p = this.#telemetry.getPosition(); if (p) return { lat: p.lat, lon: p.lon, altM: p.altM, source: p.live ? 'gps' : 'gps-last-known', ageMs: p.ageMs }; if (this.#cfg.fallbackObserver) return { ...this.#cfg.fallbackObserver, source: 'config' }; throw new ServiceError('no_position', 'No GPS fix yet and no FALLBACK_LAT/FALLBACK_LON configured', 503); } #declination(obs, date) { if (Number.isFinite(this.#cfg.declinationDeg)) return { deg: this.#cfg.declinationDeg, source: 'config' }; if (this.#geomag) { try { const info = this.#geomag.model(date).point([obs.lat, obs.lon]); if (Number.isFinite(info.decl)) return { deg: info.decl, source: 'wmm' }; } catch { /* fall through */ } } return { deg: 0, source: 'none' }; } // ───────────────────────────── prediction ───────────────────────────── /** * Where is the target, from the GPS position, at the OS time? * @param {string} spec * @param {{date?: Date, observer?: {lat:number, lon:number, altM?:number}}} [opts] * @returns {{target, time, observer, az:number, alt:number, aboveHorizon:boolean, rangeKm?:number, tle?:object}} */ predict(spec, { date = this.#telemetry.now(), observer } = {}) { const target = this.resolveTarget(spec); const obs = this.#observer(observer); const out = { target: { kind: target.kind, id: target.id, label: target.label }, time: date.toISOString(), observer: { lat: round(obs.lat, 5), lon: round(obs.lon, 5), altM: round(obs.altM, 1), source: obs.source }, }; let pos; if (target.kind === 'sat') { const s = this.#sats.get(target.norad); const pv = satellite.propagate(s.satrec, date); const p = pv?.position; if (!p || ![p.x, p.y, p.z].every(Number.isFinite)) { throw new ServiceError('propagation_failed', `SGP4 could not propagate ${target.label} to ${out.time} (TLE too old or decayed?)`, 422); } const ecf = satellite.eciToEcf(p, satellite.gstime(date)); const look = satellite.ecfToLookAngles({ latitude: rad(obs.lat), longitude: rad(obs.lon), height: obs.altM / 1000 }, ecf); pos = { az: wrap360(deg(look.azimuth)), alt: deg(look.elevation) }; out.rangeKm = round(look.rangeSat, 1); const ageDays = (date.getTime() - s.epochMs) / 86_400_000; out.tle = { epoch: new Date(s.epochMs).toISOString(), ageDays: round(ageDays, 2), stale: Math.abs(ageDays) > 14 }; } else { const astroObs = new Astronomy.Observer(obs.lat, obs.lon, obs.altM); let ra; let dec; if (target.kind === 'body') { const eq = Astronomy.Equator(target.body, date, astroObs, true, true); // topocentric, of date, with aberration ra = eq.ra; dec = eq.dec; out.distanceAu = round(eq.dist, 5); } else { // J2000 catalogue position -> equator of date (precession is ~0.3° by now, which matters at eyepiece scale) const v = Astronomy.VectorFromSphere(new Astronomy.Spherical(target.dec, target.ra * 15, 1), date); const eq = Astronomy.EquatorFromVector(Astronomy.RotateVector(Astronomy.Rotation_EQJ_EQD(date), v)); ra = eq.ra; dec = eq.dec; } const hor = Astronomy.Horizon(date, astroObs, ra, dec, 'normal'); // includes atmospheric refraction pos = { az: hor.azimuth, alt: hor.altitude }; out.raHours = round(ra, 5); out.decDeg = round(dec, 4); } out.az = round(pos.az); out.alt = round(pos.alt); out.aboveHorizon = pos.alt > 0; return out; } // ───────────────────────────── pointing / delta ───────────────────────────── #freshOrientation() { const o = this.#telemetry.getOrientation({ maxAgeMs: this.#cfg.maxOrientationAgeMs }); if (!o) { throw new ServiceError('no_orientation', 'No fresh orientation data from the IMU (sensor stale or not running)', 503, { sensorState: this.#telemetry.getSensorState(), }); } return o; } #bodyQuat(o) { return this.#cfg.invertQuat ? quatConj(o.quat) : o.quat; } /** Where the telescope is pointing right now (true az / alt). */ getPointing({ date = this.#telemetry.now(), observer } = {}) { const o = this.#freshOrientation(); const obs = this.#observer(observer); const decl = this.#declination(obs, date); const boresight = this.#alignment?.boresight ?? this.#cfg.defaultBoresight; const { az: azMag, alt } = vecToAzAlt(quatRotate(this.#bodyQuat(o), boresight)); return { az: round(wrap360(azMag + decl.deg)), alt: round(alt), aligned: Boolean(this.#alignment), declination: { deg: round(decl.deg, 2), source: decl.source }, orientationAgeMs: o.ageMs, }; } /** * How far is the telescope from the target? Positive deltaAz = target is clockwise (right when facing it), * positive deltaAlt = target is higher than where the telescope points. */ delta(spec, opts = {}) { const date = opts.date ?? this.#telemetry.now(); const target = this.predict(spec, { ...opts, date }); const pointing = this.getPointing({ ...opts, date }); const dAz = wrap180(target.az - pointing.az); const dAlt = target.alt - pointing.alt; const sep = angularSeparation(pointing.az, pointing.alt, target.az, target.alt); return { target, pointing, deltaAz: round(dAz), deltaAlt: round(dAlt), deltaAzOnSky: round(dAz * Math.cos(rad((target.alt + pointing.alt) / 2))), // true angular distance along the horizon separation: round(sep), onTarget: sep <= this.#cfg.onTargetDeg, hint: { azimuth: Math.abs(dAz) < 0.05 ? 'ok' : dAz > 0 ? 'right' : 'left', altitude: Math.abs(dAlt) < 0.05 ? 'ok' : dAlt > 0 ? 'up' : 'down', }, warnings: [ ...(pointing.aligned ? [] : ['not aligned yet: run zero() on a known object first']), ...(target.aboveHorizon ? [] : ['target is below the horizon']), ...(target.tle?.stale ? ['TLE is more than 14 days old; satellite position may be off'] : []), ], }; } // ───────────────────────────── zeroing ───────────────────────────── /** * Align the IMU to the telescope. Centre a known object in the eyepiece, hold still, then call this. * Only the sensor-to-tube mounting is solved; it stays valid for any later orientation. * Best objects: the Moon, a planet, or a bright star (satellites move too fast to centre and hold). */ async zero(spec) { const date = this.#telemetry.now(); const o = this.#freshOrientation(); const predicted = this.predict(spec, { date }); if (!predicted.aboveHorizon) { throw new ServiceError('target_below_horizon', `${predicted.target.label} is below the horizon (alt ${predicted.alt}°); you can't be pointing at it`, 422, { predicted }); } // What the telescope *would have* reported before this alignment (so the user sees the size of the correction). let before = null; try { before = this.getPointing({ date }); } catch { /* no observer/orientation: skip */ } const obs = this.#observer(); const decl = this.#declination(obs, date); const dirMag = azAltToVec(wrap360(predicted.az - decl.deg), predicted.alt); // true -> magnetic frame const boresight = quatRotate(quatConj(this.#bodyQuat(o)), dirMag); // target direction expressed in the sensor frame this.#alignment = { boresight: boresight.map((v) => round(v, 6)), createdAt: date.toISOString(), target: predicted.target, predicted: { az: predicted.az, alt: predicted.alt }, sensorAtZero: { yaw: o.yaw, pitch: o.pitch, roll: o.roll }, declination: decl, observer: predicted.observer, }; await fsp.writeFile(this.#alignmentFile, JSON.stringify(this.#alignment, null, 2)).catch((err) => { this.#log.warn(`could not persist alignment: ${err.message}`); }); const result = { aligned: true, target: predicted.target, predicted: { az: predicted.az, alt: predicted.alt }, before: before ? { az: before.az, alt: before.alt } : null, correction: before ? { deltaAz: round(wrap180(predicted.az - before.az)), deltaAlt: round(predicted.alt - before.alt), separation: round(angularSeparation(before.az, before.alt, predicted.az, predicted.alt)), } : null, declination: decl, boresight: this.#alignment.boresight, }; this.#log.info( `zeroed on ${predicted.target.label}: predicted az ${predicted.az}° alt ${predicted.alt}°` + (result.correction ? `, applied correction ${result.correction.separation}°` : '') + ` (declination ${round(decl.deg, 1)}° via ${decl.source})`, ); this.emit('zero', result); return result; } async clearZero() { this.#alignment = null; await fsp.rm(this.#alignmentFile, { force: true }); this.#log.info('alignment cleared'); this.emit('zero', { aligned: false }); return { aligned: false }; } getAlignment() { return this.#alignment ? { aligned: true, ...this.#alignment } : { aligned: false }; } getStatus() { return { alignment: this.getAlignment(), tle: this.getTleStatus() }; } }