generated from ztimson/template
init
This commit is contained in:
+1080
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,531 @@
|
||||
|
||||
// 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:<RA hours 0-24>,<Dec degrees>" (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() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
|
||||
// Telemetry service
|
||||
// * Orientation: supervises the configured Python MPU script and tracks it with an explicit state machine.
|
||||
// * Position: talks to gpsd over its JSON TCP protocol (no extra dependency).
|
||||
// * Time: the OS clock (already disciplined by RTC/GPS), exposed via now().
|
||||
//
|
||||
// Sensor state machine
|
||||
//
|
||||
// stopped ──start()──▶ starting ──first sample──▶ running ◀──sample──┐
|
||||
// │ │ │
|
||||
// │ exit / no data │ no sample │
|
||||
// ▼ ▼ for staleMs │
|
||||
// backoff ◀──── exit / stale too long ──────── stale
|
||||
// │
|
||||
// └─ delay (1s,2s,4s … 30s) ─▶ starting
|
||||
//
|
||||
// Events: 'orientation', 'sensor-state', 'gps-state', 'gps'
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn } from 'node:child_process';
|
||||
import readline from 'node:readline';
|
||||
import net from 'node:net';
|
||||
|
||||
const SENSOR_TRANSITIONS = {
|
||||
stopped: ['starting'],
|
||||
starting: ['running', 'backoff', 'stopped'],
|
||||
running: ['stale', 'backoff', 'stopped'],
|
||||
stale: ['running', 'backoff', 'stopped'],
|
||||
backoff: ['starting', 'stopped'],
|
||||
};
|
||||
|
||||
const isFiniteNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
||||
|
||||
function eulerToQuat(yaw, pitch, roll) {
|
||||
const y = (yaw * Math.PI) / 360;
|
||||
const p = (pitch * Math.PI) / 360;
|
||||
const r = (roll * Math.PI) / 360;
|
||||
const cy = Math.cos(y), sy = Math.sin(y);
|
||||
const cp = Math.cos(p), sp = Math.sin(p);
|
||||
const cr = Math.cos(r), sr = Math.sin(r);
|
||||
return [
|
||||
sr * cp * cy - cr * sp * sy,
|
||||
cr * sp * cy + sr * cp * sy,
|
||||
cr * cp * sy - sr * sp * cy,
|
||||
cr * cp * cy + sr * sp * sy,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
export class TelemetryService extends EventEmitter {
|
||||
#cfg;
|
||||
#log;
|
||||
|
||||
#sensor = {
|
||||
state: 'stopped',
|
||||
proc: null,
|
||||
startedAt: 0,
|
||||
lastSampleAt: 0,
|
||||
runningSince: 0,
|
||||
failures: 0,
|
||||
restarts: 0,
|
||||
lastError: null,
|
||||
backoffTimer: null,
|
||||
};
|
||||
#orientation = null;
|
||||
#batteryTimer = null;
|
||||
#batteryPolling = false;
|
||||
#battery = {
|
||||
state: 'unavailable', available: false, source: 'pisugar-server', socketPath: '/tmp/pisugar-server.sock',
|
||||
voltageV: null, percent: null, percentEstimate: null, percentIsEstimate: false,
|
||||
charging: null, powerPlugged: null, chargingAllowed: null, updatedAt: null, error: 'not polled yet',
|
||||
};
|
||||
|
||||
#gps = {
|
||||
socket: null,
|
||||
connected: false,
|
||||
retryTimer: null,
|
||||
connectFailures: 0,
|
||||
lastTpvAt: 0,
|
||||
tpv: null,
|
||||
sky: null,
|
||||
lastGoodFix: null,
|
||||
emittedState: 'disconnected',
|
||||
};
|
||||
|
||||
#watchdog = null;
|
||||
#stopping = true;
|
||||
|
||||
constructor(cfg, { log }) {
|
||||
super();
|
||||
this.#cfg = cfg;
|
||||
this.#log = log;
|
||||
}
|
||||
|
||||
// ───────────────────────────── lifecycle ─────────────────────────────
|
||||
|
||||
start() {
|
||||
if (!this.#stopping) return;
|
||||
this.#stopping = false;
|
||||
this.#startSensor();
|
||||
this.#connectGps();
|
||||
this.#pollBattery().catch(() => {});
|
||||
if (this.#cfg.pisugar?.enabled !== false) {
|
||||
this.#batteryTimer = setInterval(() => this.#pollBattery().catch(() => {}), this.#cfg.pisugar?.pollMs || 5000);
|
||||
this.#batteryTimer.unref();
|
||||
}
|
||||
this.#watchdog = setInterval(() => this.#tick(), 1000);
|
||||
this.#watchdog.unref();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.#stopping = true;
|
||||
clearInterval(this.#watchdog);
|
||||
clearInterval(this.#batteryTimer);
|
||||
clearTimeout(this.#sensor.backoffTimer);
|
||||
clearTimeout(this.#gps.retryTimer);
|
||||
this.#transitionSensor('stopped', 'service stopped');
|
||||
|
||||
const proc = this.#sensor.proc;
|
||||
this.#sensor.proc = null;
|
||||
if (proc) {
|
||||
await new Promise((resolve) => {
|
||||
const t = setTimeout(() => proc.kill('SIGKILL'), 2000);
|
||||
proc.once('close', () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
proc.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
this.#gps.socket?.destroy();
|
||||
this.#gps.socket = null;
|
||||
}
|
||||
|
||||
// ───────────────────────────── public API ─────────────────────────────
|
||||
|
||||
/** Current time. The OS clock is already synced to RTC/GPS. */
|
||||
now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest orientation, or null if the sensor is not delivering fresh data.
|
||||
* @param {{maxAgeMs?: number}} [opts]
|
||||
*/
|
||||
getOrientation({ maxAgeMs = this.#cfg.staleMs } = {}) {
|
||||
const o = this.#orientation;
|
||||
if (!o) return null;
|
||||
const ageMs = Date.now() - o.ts;
|
||||
if (ageMs > maxAgeMs) return null;
|
||||
return { yaw: o.yaw, pitch: o.pitch, roll: o.roll, quat: o.quat, ageMs, ts: o.ts };
|
||||
}
|
||||
|
||||
/** Last good GPS fix (kept even if the fix is currently lost) or null if there has never been one. */
|
||||
getPosition() {
|
||||
const f = this.#gps.lastGoodFix;
|
||||
if (!f) return null;
|
||||
const ageMs = Date.now() - f.at;
|
||||
return {
|
||||
lat: f.lat,
|
||||
lon: f.lon,
|
||||
altM: f.altM ?? 0,
|
||||
mode: f.mode,
|
||||
ageMs,
|
||||
live: ageMs < this.#cfg.gpsStaleMs,
|
||||
};
|
||||
}
|
||||
|
||||
getSensorState() {
|
||||
return this.#sensor.state;
|
||||
}
|
||||
|
||||
getGpsState() {
|
||||
return this.#gpsStateNow();
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
const now = Date.now();
|
||||
const s = this.#sensor;
|
||||
const o = this.#orientation;
|
||||
const g = this.#gps;
|
||||
const pos = this.getPosition();
|
||||
return {
|
||||
time: { iso: new Date(now).toISOString(), epochMs: now },
|
||||
sensor: {
|
||||
state: s.state,
|
||||
sampleAgeMs: s.lastSampleAt ? now - s.lastSampleAt : null,
|
||||
restarts: s.restarts,
|
||||
lastError: s.lastError,
|
||||
},
|
||||
orientation: o
|
||||
? { yaw: o.yaw, pitch: o.pitch, roll: o.roll, quat: o.quat, ageMs: now - o.ts }
|
||||
: null,
|
||||
battery: { ...this.#battery },
|
||||
gps: {
|
||||
state: this.#gpsStateNow(),
|
||||
position: pos,
|
||||
satellites: g.sky ? { used: g.sky.used, seen: g.sky.seen } : null,
|
||||
dop: g.sky ? { hdop: g.sky.hdop, vdop: g.sky.vdop, pdop: g.sky.pdop } : null,
|
||||
speedMps: g.tpv?.speed ?? null,
|
||||
// GPS time minus system time. Should be a few ms if the OS sync is healthy.
|
||||
clockSkewMs: g.tpv?.skewMs ?? null,
|
||||
errorM: g.tpv ? { x: g.tpv.epx ?? null, y: g.tpv.epy ?? null, v: g.tpv.epv ?? null } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────────── PiSugar battery ─────────────────────────────
|
||||
|
||||
getBattery() {
|
||||
return { ...this.#battery };
|
||||
}
|
||||
|
||||
async #pisugarCommand(command) {
|
||||
const path = this.#cfg.pisugar?.socketPath || '/tmp/pisugar-server.sock';
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ path });
|
||||
let output = '';
|
||||
let settled = false;
|
||||
const finish = (err, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
if (err) reject(err);
|
||||
else resolve(value.trim());
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error(`PiSugar socket timed out for: ${command}`)), 1500);
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('connect', () => socket.write(`${command}\n`));
|
||||
socket.on('data', (chunk) => {
|
||||
output += chunk;
|
||||
const newline = output.indexOf('\n');
|
||||
if (newline >= 0) finish(null, output.slice(0, newline));
|
||||
});
|
||||
socket.on('error', (err) => finish(err));
|
||||
socket.on('end', () => finish(null, output));
|
||||
});
|
||||
}
|
||||
|
||||
async #pollBattery() {
|
||||
const cfg = this.#cfg.pisugar || {};
|
||||
if (cfg.enabled === false || this.#batteryPolling) return;
|
||||
this.#batteryPolling = true;
|
||||
|
||||
try {
|
||||
const read = async (name) => {
|
||||
const response = await this.#pisugarCommand(`get ${name}`);
|
||||
const match = response.match(/:\s*(.+?)\s*$/);
|
||||
if (!match) throw new Error(`Unexpected PiSugar response for get ${name}: ${response}`);
|
||||
return match[1];
|
||||
};
|
||||
const [percentText, voltageText, pluggedText, allowChargingText] = await Promise.all([
|
||||
read('battery'), read('battery_v'), read('battery_power_plugged'), read('battery_allow_charging'),
|
||||
]);
|
||||
const percent = Number(percentText);
|
||||
const voltageV = Number(voltageText);
|
||||
const powerPlugged = pluggedText.toLowerCase() === 'true';
|
||||
const chargingAllowed = allowChargingText.toLowerCase() === 'true';
|
||||
if (!Number.isFinite(percent) || !Number.isFinite(voltageV)) throw new Error('PiSugar returned invalid battery values');
|
||||
|
||||
this.#battery = {
|
||||
state: 'ok', available: true, source: 'pisugar-server',
|
||||
socketPath: cfg.socketPath || '/tmp/pisugar-server.sock',
|
||||
voltageV: Math.round(voltageV * 1000) / 1000,
|
||||
percent: Math.round(percent), percentEstimate: Math.round(percent), percentIsEstimate: false,
|
||||
powerPlugged, chargingAllowed, charging: powerPlugged && chargingAllowed,
|
||||
updatedAt: new Date().toISOString(), error: null,
|
||||
};
|
||||
this.emit('battery', this.getBattery());
|
||||
} catch (err) {
|
||||
const message = err.code === 'ENOENT'
|
||||
? `PiSugar socket unavailable (${cfg.socketPath || '/tmp/pisugar-server.sock'}); is pisugar-server running?`
|
||||
: err.message;
|
||||
const shouldLog = this.#battery.error !== message;
|
||||
this.#battery = {
|
||||
...this.#battery,
|
||||
state: this.#battery.updatedAt ? 'stale' : 'unavailable',
|
||||
available: Boolean(this.#battery.updatedAt),
|
||||
source: 'pisugar-server', socketPath: cfg.socketPath || '/tmp/pisugar-server.sock', error: message,
|
||||
};
|
||||
if (shouldLog) this.#log.warn(`PiSugar battery read failed: ${message}`);
|
||||
} finally {
|
||||
this.#batteryPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── sensor ─────────────────────────────
|
||||
|
||||
#transitionSensor(next, reason) {
|
||||
const s = this.#sensor;
|
||||
if (s.state === next) return;
|
||||
if (!SENSOR_TRANSITIONS[s.state]?.includes(next)) {
|
||||
this.#log.debug(`ignored illegal sensor transition ${s.state} -> ${next}`);
|
||||
return;
|
||||
}
|
||||
const prev = s.state;
|
||||
s.state = next;
|
||||
if (next === 'running') s.runningSince = Date.now();
|
||||
this.#log.info(`sensor ${prev} -> ${next}${reason ? ` (${reason})` : ''}`);
|
||||
this.emit('sensor-state', { state: next, prev, reason });
|
||||
}
|
||||
|
||||
#startSensor() {
|
||||
if (this.#stopping) return;
|
||||
const s = this.#sensor;
|
||||
this.#transitionSensor('starting');
|
||||
s.startedAt = Date.now();
|
||||
s.lastSampleAt = 0;
|
||||
s.runningSince = 0;
|
||||
|
||||
let proc;
|
||||
try {
|
||||
proc = spawn(this.#cfg.python, [this.#cfg.script], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, BNO_ADDR: String(this.#cfg.bnoAddress), BNO_HZ: String(this.#cfg.bnoHz) },
|
||||
});
|
||||
} catch (err) {
|
||||
this.#sensorDied(null, `spawn failed: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
s.proc = proc;
|
||||
|
||||
readline.createInterface({ input: proc.stdout }).on('line', (l) => this.#onSensorLine(l));
|
||||
readline.createInterface({ input: proc.stderr }).on('line', (l) => this.#onSensorStderr(l));
|
||||
proc.on('error', (err) => this.#sensorDied(proc, `process error: ${err.message}`));
|
||||
proc.on('exit', (code, signal) => this.#sensorDied(proc, `exited (code=${code}, signal=${signal})`));
|
||||
}
|
||||
|
||||
#onSensorLine(line) {
|
||||
let d;
|
||||
try {
|
||||
d = JSON.parse(line);
|
||||
} catch {
|
||||
this.#log.debug(`non-JSON from sensor: ${line.slice(0, 120)}`);
|
||||
return;
|
||||
}
|
||||
if (![d.yaw, d.pitch, d.roll].every(isFiniteNum)) return;
|
||||
const q = Array.isArray(d.quat) && d.quat.length === 4 && d.quat.every(isFiniteNum)
|
||||
? d.quat
|
||||
: eulerToQuat(d.yaw, d.pitch, d.roll);
|
||||
|
||||
const now = Date.now();
|
||||
this.#orientation = { yaw: d.yaw, pitch: d.pitch, roll: d.roll, quat: q, ts: now };
|
||||
this.#sensor.lastSampleAt = now;
|
||||
|
||||
const st = this.#sensor.state;
|
||||
if (st === 'starting' || st === 'stale') this.#transitionSensor('running', 'receiving samples');
|
||||
this.emit('orientation', this.#orientation);
|
||||
}
|
||||
|
||||
#onSensorStderr(line) {
|
||||
let msg = line;
|
||||
let level = 'warn';
|
||||
try {
|
||||
const d = JSON.parse(line);
|
||||
if (d.error) {
|
||||
level = 'error';
|
||||
msg = d.error;
|
||||
this.#sensor.lastError = String(d.error);
|
||||
} else if (d.warning) {
|
||||
msg = d.warning;
|
||||
}
|
||||
} catch {
|
||||
/* plain traceback line */
|
||||
}
|
||||
this.#log[level](`bno: ${msg}`);
|
||||
}
|
||||
|
||||
/** Called when the python process exits/errors. `proc` guards against double-handling. */
|
||||
#sensorDied(proc, reason) {
|
||||
const s = this.#sensor;
|
||||
if (proc && s.proc !== proc) return; // already handled
|
||||
s.proc = null;
|
||||
if (this.#stopping) return;
|
||||
|
||||
s.lastError = reason;
|
||||
// A process that ran healthily for a while resets the backoff.
|
||||
if (s.runningSince && Date.now() - s.runningSince > 15000) s.failures = 0;
|
||||
s.failures += 1;
|
||||
s.restarts += 1;
|
||||
|
||||
const delay = Math.min(30000, 1000 * 2 ** Math.min(s.failures - 1, 5));
|
||||
this.#transitionSensor('backoff', `${reason}; retry in ${delay / 1000}s`);
|
||||
clearTimeout(s.backoffTimer);
|
||||
s.backoffTimer = setTimeout(() => this.#startSensor(), delay);
|
||||
}
|
||||
|
||||
// ───────────────────────────── gpsd ─────────────────────────────
|
||||
|
||||
#connectGps() {
|
||||
if (this.#stopping) return;
|
||||
const g = this.#gps;
|
||||
const { gpsdHost: host, gpsdPort: port } = this.#cfg;
|
||||
const sock = net.createConnection({ host, port });
|
||||
sock.setEncoding('utf8');
|
||||
sock.setKeepAlive(true, 10000);
|
||||
g.socket = sock;
|
||||
|
||||
let buf = '';
|
||||
sock.on('connect', () => {
|
||||
g.connected = true;
|
||||
g.connectFailures = 0;
|
||||
this.#log.info(`gpsd connected (${host}:${port})`);
|
||||
sock.write('?WATCH={"enable":true,"json":true}\n');
|
||||
this.#emitGpsStateIfChanged();
|
||||
});
|
||||
sock.on('data', (chunk) => {
|
||||
buf += chunk;
|
||||
let i;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, i).trim();
|
||||
buf = buf.slice(i + 1);
|
||||
if (line) this.#onGpsLine(line);
|
||||
}
|
||||
if (buf.length > 1_000_000) buf = '';
|
||||
});
|
||||
sock.on('error', (err) => {
|
||||
// Don't spam the dashboard if gpsd simply isn't running.
|
||||
if (g.connectFailures === 0 || g.connectFailures % 20 === 0) {
|
||||
this.#log.warn(`gpsd connection error: ${err.code || err.message}`);
|
||||
}
|
||||
});
|
||||
sock.on('close', () => {
|
||||
if (g.socket === sock) g.socket = null;
|
||||
const wasConnected = g.connected;
|
||||
g.connected = false;
|
||||
g.connectFailures += 1;
|
||||
if (wasConnected) this.#log.warn('gpsd disconnected');
|
||||
this.#emitGpsStateIfChanged();
|
||||
if (this.#stopping) return;
|
||||
clearTimeout(g.retryTimer);
|
||||
g.retryTimer = setTimeout(() => this.#connectGps(), Math.min(10000, 2000 + g.connectFailures * 1000));
|
||||
});
|
||||
}
|
||||
|
||||
#onGpsLine(line) {
|
||||
let d;
|
||||
try {
|
||||
d = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const g = this.#gps;
|
||||
if (d.class === 'TPV') {
|
||||
const now = Date.now();
|
||||
const tpv = { ...d, receivedAt: now };
|
||||
if (d.time) {
|
||||
const t = Date.parse(d.time);
|
||||
if (Number.isFinite(t)) tpv.skewMs = Math.round(t - now);
|
||||
}
|
||||
g.tpv = tpv;
|
||||
g.lastTpvAt = now;
|
||||
if (d.mode >= 2 && isFiniteNum(d.lat) && isFiniteNum(d.lon)) {
|
||||
g.lastGoodFix = {
|
||||
lat: d.lat,
|
||||
lon: d.lon,
|
||||
altM: [d.altMSL, d.altHAE, d.alt].find(isFiniteNum) ?? null,
|
||||
mode: d.mode,
|
||||
at: now,
|
||||
};
|
||||
this.emit('gps', this.getPosition());
|
||||
}
|
||||
this.#emitGpsStateIfChanged();
|
||||
} else if (d.class === 'SKY') {
|
||||
const sats = Array.isArray(d.satellites) ? d.satellites : null;
|
||||
g.sky = {
|
||||
used: d.uSat ?? sats?.filter((s) => s.used).length ?? null,
|
||||
seen: d.nSat ?? sats?.length ?? null,
|
||||
hdop: d.hdop ?? null,
|
||||
vdop: d.vdop ?? null,
|
||||
pdop: d.pdop ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** disconnected | no_gpsd_data | no_fix | fix_2d | fix_3d */
|
||||
#gpsStateNow() {
|
||||
const g = this.#gps;
|
||||
if (!g.connected) return 'disconnected';
|
||||
if (!g.tpv) return 'no_gpsd_data';
|
||||
if (Date.now() - g.lastTpvAt > this.#cfg.gpsStaleMs) return 'no_gpsd_data';
|
||||
if (g.tpv.mode === 3) return 'fix_3d';
|
||||
if (g.tpv.mode === 2) return 'fix_2d';
|
||||
return 'no_fix';
|
||||
}
|
||||
|
||||
#emitGpsStateIfChanged() {
|
||||
const now = this.#gpsStateNow();
|
||||
if (now === this.#gps.emittedState) return;
|
||||
const prev = this.#gps.emittedState;
|
||||
this.#gps.emittedState = now;
|
||||
this.#log.info(`gps ${prev} -> ${now}`);
|
||||
this.emit('gps-state', { state: now, prev });
|
||||
}
|
||||
|
||||
// ───────────────────────────── watchdog ─────────────────────────────
|
||||
|
||||
#tick() {
|
||||
const now = Date.now();
|
||||
const s = this.#sensor;
|
||||
const { staleMs, restartAfterMs, startTimeoutMs } = this.#cfg;
|
||||
|
||||
if (s.state === 'running' && now - s.lastSampleAt > staleMs) {
|
||||
this.#transitionSensor('stale', `no sample for ${now - s.lastSampleAt} ms`);
|
||||
} else if (s.state === 'stale' && now - s.lastSampleAt > restartAfterMs) {
|
||||
this.#log.warn('sensor stale too long; restarting python');
|
||||
s.proc?.kill('SIGKILL'); // 'exit' handler moves us to backoff
|
||||
} else if (s.state === 'starting' && now - s.startedAt > startTimeoutMs) {
|
||||
this.#log.warn('sensor produced no data after start; restarting python');
|
||||
s.proc?.kill('SIGKILL');
|
||||
}
|
||||
|
||||
this.#emitGpsStateIfChanged(); // catches "TPV stopped arriving"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user