All checks were successful
Deploy production / deploy (push) Successful in 1m20s
276 lines
9.4 KiB
TypeScript
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();
|
|
}
|