radio: private radio tracker (/trackers/radio): frequencies, UBC125XLT banks + CSV, nets, ISS passes over Gouda, listening log, ntfy reminders
All checks were successful
Deploy production / deploy (push) Successful in 1m26s

This commit is contained in:
2026-10-26 16:58:29 +00:00
parent e24ee7f95d
commit 03574a95a7
37 changed files with 48839 additions and 3 deletions

314
src/lib/radio/index.ts Normal file
View File

@@ -0,0 +1,314 @@
// Radio tracker (/trackers/radio, owner-only): pure helpers shared by the page and
// scripts/radio.ts (ISS refresh + reminders). No server imports here: the script runs inside the
// app container, which ships src/lib/radio (see Dockerfile).
export const TZ = 'Europe/Amsterdam';
export const BANDS = ['marine', 'air', 'ham', 'pmr', 'other'] as const;
export type Band = (typeof BANDS)[number];
export const BAND_LABEL: Record<Band, string> = {
marine: 'marine',
air: 'airband',
ham: 'ham',
pmr: 'PMR',
other: 'other'
};
/** Gradient per band: colourful icons on a black-and-white page. */
export const BAND_COLOR: Record<Band, [string, string]> = {
marine: ['#38bdf8', '#0369a1'],
air: ['#a78bfa', '#5b21b6'],
ham: ['#fb7185', '#be123c'],
pmr: ['#fbbf24', '#b45309'],
other: ['#a1a1aa', '#3f3f46']
};
export const MODES = ['FM', 'NFM', 'AM', 'USB', 'LSB', 'CW', 'DATA'] as const;
export type Mode = (typeof MODES)[number];
/** Modes the UBC125XLT can store (AUTO aside); others export as AUTO. */
export const SCANNER_MODES = new Set<string>(['FM', 'NFM', 'AM']);
/** Uniden UBC125XLT layout: 10 banks of 50 channels. */
export const BANKS = 10;
export const PER_BANK = 50;
export const CHANNELS = BANKS * PER_BANK;
export const bankOf = (channel: number) => Math.floor((channel - 1) / PER_BANK) + 1;
export const bankRange = (bank: number) => [(bank - 1) * PER_BANK + 1, bank * PER_BANK] as const;
/** The UBC125XLT shows 16-character alpha tags. */
export const ALPHA_TAG_MAX = 16;
/** Minimum max-elevation for an ISS pass to count (shown highlighted, reminded). */
export const ISS_MIN_EL = 10;
export const REMIND_MINUTES = 10;
export const ISS_DOWNLINK_HZ = 145_800_000;
export const DAY_SHORT = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
/** 145450000 -> "145.4500" (MHz, 4 decimals like the scanner); below 30 MHz -> "3785 kHz". */
export function formatFreq(hz: number | null | undefined, unit = true): string {
if (hz == null) return '';
if (hz < 30_000_000) {
const khz = hz / 1000;
return `${Number.isInteger(khz) ? khz : khz.toFixed(1)}${unit ? ' kHz' : ''}`;
}
return `${(hz / 1e6).toFixed(4)}${unit ? ' MHz' : ''}`;
}
/** MHz with 4 decimals, no unit (CSV/programming software). */
export const mhz = (hz: number) => (hz / 1e6).toFixed(4);
/**
* Parse a typed frequency: "145.45", "145.450 MHz", "3785 kHz", "3785k", "145450000".
* Bare numbers < 1000 are MHz, 1000..999999 kHz, larger Hz. Returns Hz or null.
*/
export function parseFreq(input: string | null | undefined): number | null {
if (!input) return null;
const s = input.trim().toLowerCase().replace(',', '.').replace(/\s+/g, '');
const m = s.match(/^(\d+(?:\.\d+)?)(mhz|m|khz|k|hz)?$/);
if (!m) return null;
const n = Number(m[1]);
if (!Number.isFinite(n) || n <= 0) return null;
const u = m[2];
let hz: number;
if (u === 'mhz' || u === 'm') hz = n * 1e6;
else if (u === 'khz' || u === 'k') hz = n * 1e3;
else if (u === 'hz') hz = n;
else if (n < 1000) hz = n * 1e6;
else if (n < 1_000_000) hz = n * 1e3;
else hz = n;
hz = Math.round(hz);
return hz >= 1000 && hz <= 10_000_000_000 ? hz : null;
}
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const;
export const compass = (az: number) => COMPASS[Math.round((((az % 360) + 360) % 360) / 45) % 8];
// ---- time zones -------------------------------------------------------------------------------
const partsFmt = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
weekday: 'short'
});
const WD: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
/** Wall-clock parts of an instant in Europe/Amsterdam (isoDay 1 = Monday). */
export function zoned(at: Date) {
const p = Object.fromEntries(partsFmt.formatToParts(at).map((x) => [x.type, x.value]));
return {
y: Number(p.year),
m: Number(p.month),
d: Number(p.day),
h: Number(p.hour),
min: Number(p.minute),
s: Number(p.second),
isoDay: WD[p.weekday as string] ?? 1
};
}
/** The instant at which Europe/Amsterdam's wall clock reads y-m-d h:min. */
export function fromZoned(y: number, m: number, d: number, h: number, min: number): Date {
const guess = Date.UTC(y, m - 1, d, h, min);
// Offset at the guess, then correct once more for DST edges.
let t = guess;
for (let i = 0; i < 2; i++) {
const z = zoned(new Date(t));
const asUtc = Date.UTC(z.y, z.m - 1, z.d, z.h, z.min, z.s);
t = guess - (asUtc - t);
}
return new Date(t);
}
/** "11:00" / "11:00:00" -> [11, 0]. */
export const hm = (t: string) => {
const [h, m] = t.split(':').map(Number);
return [h || 0, m || 0] as const;
};
export type NetSchedule = { id: number; days: number[]; times: string[]; active?: boolean };
/** Occurrences of a weekly schedule (Amsterdam wall time) between from and to. */
export function occurrences(net: NetSchedule, from: Date, to: Date): Date[] {
if (net.active === false || !net.days.length || !net.times.length) return [];
const out: Date[] = [];
const start = zoned(new Date(from.getTime() - 86_400_000));
// Walk calendar days in Amsterdam (via noon UTC so DST never skips a day).
let cursor = Date.UTC(start.y, start.m - 1, start.d, 12);
while (cursor <= to.getTime() + 86_400_000) {
const dt = new Date(cursor);
const y = dt.getUTCFullYear();
const m = dt.getUTCMonth() + 1;
const d = dt.getUTCDate();
const isoDay = ((dt.getUTCDay() + 6) % 7) + 1;
if (net.days.includes(isoDay)) {
for (const t of net.times) {
const [h, mi] = hm(t);
const at = fromZoned(y, m, d, h, mi);
if (at > from && at <= to) out.push(at);
}
}
cursor += 86_400_000;
}
return out.sort((a, b) => a.getTime() - b.getTime());
}
export const nextOccurrence = (net: NetSchedule, now: Date) =>
occurrences(net, now, new Date(now.getTime() + 8 * 86_400_000))[0] ?? null;
const timeFmt = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
});
const dayFmt = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
weekday: 'short',
day: 'numeric',
month: 'short'
});
export const fmtTime = (at: Date | string | number) => timeFmt.format(new Date(at));
export const fmtDay = (at: Date | string | number) => dayFmt.format(new Date(at));
/** Amsterdam calendar date "2026-10-26" (for grouping). */
export const dayKey = (at: Date | string | number) => {
const z = zoned(new Date(at));
return `${z.y}-${String(z.m).padStart(2, '0')}-${String(z.d).padStart(2, '0')}`;
};
/** "today", "tomorrow" or "Wed 28 Oct". */
export function relDay(at: Date | string | number, now: Date | number = Date.now()) {
const k = dayKey(at);
if (k === dayKey(now)) return 'today';
if (k === dayKey(new Date(now).getTime() + 86_400_000)) return 'tomorrow';
return fmtDay(at);
}
/** "in 5 min", "in 2 h 10 min", "now". */
export function until(at: Date | string | number, now: Date | number = Date.now()) {
const mins = Math.round((new Date(at).getTime() - new Date(now).getTime()) / 60_000);
if (mins <= 0) return 'now';
if (mins < 60) return `in ${mins} min`;
const h = Math.floor(mins / 60);
if (h < 24) return `in ${h} h${mins % 60 ? ` ${mins % 60} min` : ''}`;
const d = Math.round(h / 24);
return `in ${d} day${d === 1 ? '' : 's'}`;
}
/** Days as text: [1,2,3,4,5] -> "weekdays", all 7 -> "daily", else "mon, wed". */
export function daysLabel(days: number[]) {
const s = [...new Set(days)].sort();
if (s.length === 7) return 'daily';
if (s.join() === '1,2,3,4,5') return 'weekdays';
if (s.join() === '6,7') return 'weekends';
return s.map((d) => DAY_SHORT[d - 1]).join(', ');
}
// ---- scanner CSV ------------------------------------------------------------------------------
export type ChannelRow = {
channel: number;
alphaTag: string | null;
freqHz: number | null;
mode: string | null;
delay: boolean;
lockout: boolean;
priority: boolean;
};
const csvCell = (v: string | number) => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
/**
* All 500 channels as CSV in the column order of Uniden's BC125AT/UBC125XLT programming software
* (BC125AT_SS: Channel, Name, Frequency, Modulation, CTCSS/DCS, Delay, Lockout, Priority) plus a
* leading Bank column. Empty channels are blank rows so the file always maps 1:1 to memories.
*/
export function scannerCsv(rows: ChannelRow[]): string {
const by = new Map(rows.map((r) => [r.channel, r]));
const lines = [
[
'Bank',
'Channel',
'Name',
'Frequency',
'Modulation',
'CTCSS/DCS',
'Delay',
'Lockout',
'Priority'
]
.map(csvCell)
.join(',')
];
for (let ch = 1; ch <= CHANNELS; ch++) {
const r = by.get(ch);
const has = r && r.freqHz != null;
lines.push(
[
bankOf(ch),
ch,
has ? (r.alphaTag ?? '').slice(0, ALPHA_TAG_MAX) : '',
has ? mhz(r.freqHz!) : '',
has ? (SCANNER_MODES.has(r.mode ?? '') ? r.mode! : 'AUTO') : 'AUTO',
'OFF',
has ? (r.delay ? 2 : 0) : 2,
has ? (r.lockout ? 'ON' : 'OFF') : 'OFF',
has ? (r.priority ? 'ON' : 'OFF') : 'OFF'
]
.map(csvCell)
.join(',')
);
}
return lines.join('\r\n') + '\r\n';
}
/** Short scanner tag from a station name (abbreviations, then cut to 16 characters). */
export function alphaTag(name: string, marineCh?: string | null): string {
const ABBR: [RegExp, string][] = [
[/\bSchiphol\b/gi, 'SPL'],
[/\bRotterdam\b/gi, 'RTM'],
[/\bScheveningen\b/gi, 'SCHEV'],
[/\bMaastricht\b/gi, 'MUAC'],
[/\bBrussel\b/gi, 'BRU'],
[/\bLondon\b/gi, 'LON'],
[/\bNieuw Milligen\b/gi, 'MIL'],
[/\bDordrecht\b/gi, 'DORD'],
[/\bKustwacht\b/gi, 'KWC'],
[/\bTower\b|\bToren\b/gi, 'TWR'],
[/\bApproach\b/gi, 'APP'],
[/\bArrival\b/gi, 'ARR'],
[/\bDeparture\b/gi, 'DEP'],
[/\bRadar\b/gi, 'RDR'],
[/\bOperations\b/gi, 'OPS'],
[/\bEurocontrol\b/gi, ''],
[/\bArea Control\b/gi, 'AC'],
[/\bSector\b/gi, ''],
[/\bBaan\b/gi, ''],
[/\bZweefvliegtuigen\b/gi, 'Gliders'],
[/\bInternational(e)?\b|\bInternationaal\b/gi, 'Intl'],
[/\bEmergency Frequency\b/gi, 'Emergency'],
[/\bReddings\s*Brigade\b/gi, 'RB'],
[/\bSluizen\b/gi, ''],
[/\bVolkeraksluizen\b/gi, 'Volkerak'],
[/\bSleep Dienst\b/gi, 'Tug'],
[/\bScheepvaartbedrijven\b|\bScheepvaart bedrijven\b/gi, 'Shipping'],
[/\bRijkswaterstaat\b/gi, 'RWS'],
[/\bLandelijk Scheepsportofoon\b/gi, 'Ship UHF'],
[/\bKanaal\b/gi, 'ch'],
[/\bSchip\b/gi, 'S'],
[/\bWal\b/gi, 'W']
];
let s = name;
for (const [re, to] of ABBR) s = s.replace(re, to);
s = s
.replace(/\([^)]*\)/g, '')
.replace(/\s+/g, ' ')
.trim();
if (marineCh) s = `${marineCh} ${s}`;
return s.slice(0, ALPHA_TAG_MAX).trim();
}