Files
website/scripts/radio.ts
2026-10-26 17:24:03 +00:00

276 lines
9.4 KiB
TypeScript

/**
* Radio tracker jobs (/trackers/radio). Runs inside the app container like cs-prices:
*
* bun scripts/radio.ts iss fetch the ISS TLE from Celestrak, compute the next 5 days of
* passes over Gouda into radio.iss_passes (daily timer)
* bun scripts/radio.ts remind ntfy reminder 10 minutes before each net (remind = true) and
* each ISS pass with max elevation >= 10 deg (every-minute timer)
* bun scripts/radio.ts upcoming print what the next 24 h of reminders would be (no sending)
*
* Reminders go to RADIO_NTFY_URL (an ntfy topic URL, optional RADIO_NTFY_TOKEN), passed in with
* `docker exec --env-file ~/.config/radio.env`. radio.reminders makes every reminder fire once.
* RADIO_QUIET_HOURS (default 00:00-08:00, "off" to disable) skips reminders sent in that window.
*/
import postgres from 'postgres';
import * as sat from 'satellite.js';
import {
ISS_DOWNLINK_HZ,
ISS_MIN_EL,
QUIET_HOURS,
REMIND_MINUTES,
compass,
fmtTime,
formatFreq,
inQuiet,
occurrences,
parseQuiet
} from '../src/lib/radio/index';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 2, onnotice: () => {} });
const mode = process.argv[2] ?? 'remind';
// Quiet hours (Amsterdam): reminders whose send time falls inside are skipped. "off" disables.
const QUIET = parseQuiet(process.env.RADIO_QUIET_HOURS ?? QUIET_HOURS);
const sendAt = (at: Date) => new Date(at.getTime() - REMIND_MINUTES * 60_000);
const quiet = (at: Date) => inQuiet(sendAt(at), QUIET);
const PAGE = 'https://stanrunge.dev/trackers/radio';
// Gouda (approx), 0 m.
const OBSERVER = {
latitude: sat.degreesToRadians(52.011),
longitude: sat.degreesToRadians(4.711),
height: 0
};
const TLE_URL = 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle';
const DAYS = 5;
const STEP_S = 10;
const KM_PER_AU = 149_597_870.7;
async function fetchTle() {
const res = await fetch(TLE_URL, {
headers: { 'User-Agent': 'stanrunge.dev radio-tracker/1.0 (daily ISS passes)' },
signal: AbortSignal.timeout(20_000)
});
if (!res.ok) throw new Error(`Celestrak ${res.status}`);
const lines = (await res.text())
.split(/\r?\n/)
.map((l) => l.trimEnd())
.filter(Boolean);
const i = lines.findIndex((l) => l.startsWith('1 25544'));
if (i < 0 || !lines[i + 1]?.startsWith('2 25544')) throw new Error('No ISS TLE in the response');
return {
name: (i > 0 ? lines[i - 1] : 'ISS (ZARYA)').trim(),
line1: lines[i],
line2: lines[i + 1]
};
}
function look(satrec: sat.SatRec, at: Date) {
const pv = sat.propagate(satrec, at);
if (!pv || typeof pv.position === 'boolean' || !pv.position) return null;
const gmst = sat.gstime(at);
const la = sat.ecfToLookAngles(OBSERVER, sat.eciToEcf(pv.position, gmst));
return {
el: sat.radiansToDegrees(la.elevation),
az: sat.radiansToDegrees(la.azimuth),
eci: pv.position,
gmst
};
}
/** ISS in sunlight while the sun is >= 6 deg below the observer's horizon (naked-eye visible). */
function sunlitInDarkSky(at: Date, eci: sat.EciVec3<number>, gmst: number) {
const jd = sat.jday(at);
const { rsun } = sat.sunPos(jd);
const sunKm = { x: rsun.x * KM_PER_AU, y: rsun.y * KM_PER_AU, z: rsun.z * KM_PER_AU };
const sunEl = sat.radiansToDegrees(
sat.ecfToLookAngles(OBSERVER, sat.eciToEcf(sunKm, gmst)).elevation
);
return sunEl < -6 && sat.shadowFraction(rsun, eci) < 0.5;
}
type Pass = {
start_at: Date;
max_at: Date;
end_at: Date;
max_el: number;
start_az: number;
max_az: number;
end_az: number;
sunlit: boolean;
};
function passes(satrec: sat.SatRec, from: Date, days: number): Pass[] {
const out: Pass[] = [];
const end = from.getTime() + days * 86_400_000;
let cur: Pass | null = null;
let prevEl = -90;
for (let t = from.getTime(); t <= end; t += STEP_S * 1000) {
const at = new Date(t);
const l = look(satrec, at);
if (!l) continue;
if (l.el >= 0 && prevEl < 0) {
cur = {
start_at: at,
max_at: at,
end_at: at,
max_el: l.el,
start_az: l.az,
max_az: l.az,
end_az: l.az,
sunlit: false
};
}
if (cur && l.el >= 0) {
if (l.el > cur.max_el) Object.assign(cur, { max_el: l.el, max_at: at, max_az: l.az });
if (!cur.sunlit && l.el >= ISS_MIN_EL && sunlitInDarkSky(at, l.eci, l.gmst))
cur.sunlit = true;
cur.end_at = at;
cur.end_az = l.az;
}
if (cur && l.el < 0) {
// Skip a pass already in progress at the start of the window.
if (cur.start_at.getTime() > from.getTime()) out.push(cur);
cur = null;
}
prevEl = l.el;
}
return out;
}
async function refreshIss() {
const tle = await fetchTle();
const satrec = sat.twoline2satrec(tle.line1, tle.line2);
const epoch = new Date((satrec.jdsatepoch - 2440587.5) * 86_400_000);
const now = new Date();
const list = passes(satrec, now, DAYS).filter((p) => p.max_el >= 1);
await sql.begin(async (tx) => {
await tx`INSERT INTO radio.iss_tle (id, name, line1, line2, epoch, fetched_at)
VALUES (1, ${tle.name}, ${tle.line1}, ${tle.line2}, ${epoch}, now())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, line1 = EXCLUDED.line1,
line2 = EXCLUDED.line2, epoch = EXCLUDED.epoch, fetched_at = now()`;
// Future passes are replaced by the fresh prediction; past ones are kept a week.
await tx`DELETE FROM radio.iss_passes WHERE start_at > now() OR end_at < now() - interval '7 days'`;
if (list.length)
await tx`INSERT INTO radio.iss_passes ${tx(list)} ON CONFLICT (start_at) DO NOTHING`;
});
const good = list.filter((p) => p.max_el >= ISS_MIN_EL).length;
console.log(
`${now.toISOString()} iss: TLE epoch ${epoch.toISOString()}, ${list.length} passes in ${DAYS} days (${good} >= ${ISS_MIN_EL} deg)`
);
}
async function ntfy(title: string, message: string, tags: string[]) {
const target = process.env.RADIO_NTFY_URL;
if (!target) {
console.log(`no RADIO_NTFY_URL; would send: ${title} | ${message}`);
return false;
}
const url = new URL(target);
const res = await fetch(url.origin, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(process.env.RADIO_NTFY_TOKEN
? { authorization: `Bearer ${process.env.RADIO_NTFY_TOKEN}` }
: {})
},
body: JSON.stringify({
topic: url.pathname.replace(/^\/+|\/+$/g, ''),
title,
message,
click: PAGE,
tags,
priority: 3
}),
signal: AbortSignal.timeout(10_000)
}).catch(() => null);
return Boolean(res?.ok);
}
type Due = {
kind: 'net' | 'iss';
refId: number;
at: Date;
title: string;
message: string;
tags: string[];
};
async function due(from: Date, to: Date): Promise<Due[]> {
const out: Due[] = [];
const nets = await sql`SELECT n.id, n.name, n.days, n.times, n.active, n.notes,
f.freq_hz::float8 AS hz, f.mode, f.marine_ch
FROM radio.nets n LEFT JOIN radio.frequencies f ON f.id = n.frequency_id
WHERE n.remind AND n.active`;
for (const n of nets) {
for (const at of occurrences(
{ id: n.id, days: n.days.map(Number), times: n.times },
from,
to
)) {
const freq = n.hz
? `${formatFreq(n.hz)} ${n.mode}${n.marine_ch ? ` (marine ch ${n.marine_ch})` : ''}`
: '';
out.push({
kind: 'net',
refId: n.id,
at,
title: `${n.name} at ${fmtTime(at)}`,
message: [`Starts in ${REMIND_MINUTES} min`, freq].filter(Boolean).join(' · '),
tags: ['radio']
});
}
}
const iss = await sql`SELECT start_at, max_at, end_at, max_el, start_az, max_az, end_az, sunlit
FROM radio.iss_passes WHERE start_at > ${from} AND start_at <= ${to} AND max_el >= ${ISS_MIN_EL}`;
for (const p of iss) {
const mins = Math.round((p.end_at.getTime() - p.start_at.getTime()) / 60_000);
out.push({
kind: 'iss',
refId: 0,
at: p.start_at,
title: `ISS pass at ${fmtTime(p.start_at)}, max ${Math.round(p.max_el)}°`,
message:
`${fmtTime(p.start_at)} ${compass(p.start_az)} → max ${Math.round(p.max_el)}° ${compass(p.max_az)} at ${fmtTime(p.max_at)} → ${compass(p.end_az)} ${fmtTime(p.end_at)} (${mins} min)` +
` · listen on ${formatFreq(ISS_DOWNLINK_HZ)} NFM` +
(p.sunlit ? ' · visible to the eye' : ''),
tags: ['satellite']
});
}
return out.sort((a, b) => a.at.getTime() - b.at.getTime());
}
async function remind() {
const now = new Date();
const list = await due(now, new Date(now.getTime() + REMIND_MINUTES * 60_000));
for (const r of list) {
if (quiet(r.at)) continue;
const [claimed] = await sql`INSERT INTO radio.reminders (kind, ref_id, at)
VALUES (${r.kind}, ${r.refId}, ${r.at}) ON CONFLICT DO NOTHING RETURNING at`;
if (!claimed) continue;
const ok = await ntfy(r.title, r.message, r.tags);
if (!ok) {
// Let the next minute's run try again (it stops once the event has started).
await sql`DELETE FROM radio.reminders WHERE kind = ${r.kind} AND ref_id = ${r.refId} AND at = ${r.at}`;
console.log(`${now.toISOString()} remind FAILED: ${r.title}`);
} else console.log(`${now.toISOString()} reminded: ${r.title} | ${r.message}`);
}
await sql`DELETE FROM radio.reminders WHERE at < now() - interval '30 days'`;
}
try {
if (mode === 'iss') await refreshIss();
else if (mode === 'remind') await remind();
else if (mode === 'upcoming') {
const now = new Date();
console.log(`quiet hours: ${QUIET?.label ?? 'off'} (Europe/Amsterdam, by send time)`);
for (const r of await due(now, new Date(now.getTime() + 86_400_000)))
console.log(
`${quiet(r.at) ? 'QUIET-SKIP' : 'SEND '} at ${fmtTime(sendAt(r.at))} ${r.kind}: ${r.title}`
);
} else throw new Error(`unknown mode ${mode} (iss | remind | upcoming)`);
} finally {
await sql.end();
}