Files
website/src/lib/server/db/schema/radio.ts

187 lines
6.1 KiB
TypeScript

import { sql } from 'drizzle-orm';
import {
bigint,
boolean,
check,
index,
integer,
primaryKey,
real,
smallint,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { radioSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Radio tracker (/trackers/radio, owner-only): frequencies, the scanner's memory banks (Uniden
// UBC125XLT, 10 x 50 channels), nets and broadcast schedules, ISS passes over Gouda and a
// listening log. scripts/radio.ts refreshes the ISS passes (daily timer) and sends the ntfy
// reminders (every-minute timer). Bands, modes and the bank layout live in #lib/radio.
export const radioFrequencies = radioSchema.table(
'frequencies',
{
id: id(),
freqHz: bigint('freq_hz', { mode: 'number' }).notNull(),
/** FM | NFM | AM | USB | LSB | CW | DATA */
mode: text('mode').notNull().default('FM'),
/** marine | air | ham | pmr | other */
band: text('band').notNull(),
name: text('name').notNull(),
location: text('location'),
notes: text('notes'),
/** Marine VHF channel number ("16", "83"), when it is one. */
marineCh: text('marine_ch'),
tags: text('tags')
.array()
.notNull()
.default(sql`'{}'::text[]`),
source: text('source'),
...timestamps
},
(t) => [
uniqueIndex('frequencies_freq_name_key').on(t.freqHz, t.name),
index('frequencies_band_idx').on(t.band),
check('frequencies_mode_check', sql`${t.mode} IN ('FM','NFM','AM','USB','LSB','CW','DATA')`),
check('frequencies_band_check', sql`${t.band} IN ('marine','air','ham','pmr','other')`),
check('frequencies_freq_check', sql`${t.freqHz} > 0`)
]
);
/** Scanner memories: channel 1..500 (bank = (channel - 1) / 50 + 1). */
export const radioChannels = radioSchema.table(
'channels',
{
channel: smallint('channel').primaryKey(),
frequencyId: integer('frequency_id')
.notNull()
.references(() => radioFrequencies.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
/** Up to 16 characters on the UBC125XLT; null = derived from the frequency's name. */
alphaTag: text('alpha_tag'),
delay: boolean('delay').notNull().default(true),
lockout: boolean('lockout').notNull().default(false),
priority: boolean('priority').notNull().default(false),
updatedAt: timestamptz('updated_at').notNull().defaultNow()
},
(t) => [
index('channels_frequency_idx').on(t.frequencyId),
check('channels_channel_check', sql`${t.channel} BETWEEN 1 AND 500`),
check('channels_alpha_tag_check', sql`char_length(${t.alphaTag}) <= 16`)
]
);
/** Nets and broadcasts on a weekly schedule, in Europe/Amsterdam wall time. */
export const radioNets = radioSchema.table(
'nets',
{
id: id(),
name: text('name').notNull(),
frequencyId: integer('frequency_id').references(() => radioFrequencies.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
/** ISO weekdays, 1 = Monday .. 7 = Sunday. */
days: smallint('days')
.array()
.notNull()
.default(sql`'{}'::smallint[]`),
/** "HH:MM" start times; empty = time unknown (listed, never reminded). */
times: text('times')
.array()
.notNull()
.default(sql`'{}'::text[]`),
durationMin: integer('duration_min'),
kind: text('kind').notNull().default('net'),
notes: text('notes'),
/** ntfy reminder 10 minutes before each start. */
remind: boolean('remind').notNull().default(true),
active: boolean('active').notNull().default(true),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(t) => [
check('nets_kind_check', sql`${t.kind} IN ('net','broadcast','other')`),
check('nets_days_check', sql`${t.days} <@ '{1,2,3,4,5,6,7}'::smallint[]`),
check(
'nets_times_check',
sql`array_to_string(${t.times}, ',') ~ '^(([01][0-9]|2[0-3]):[0-5][0-9](,|$))*$'`
)
]
);
/** ISS passes over Gouda (52.011 N, 4.711 E), from Celestrak's TLE, refreshed daily. */
export const radioIssPasses = radioSchema.table(
'iss_passes',
{
startAt: timestamptz('start_at').primaryKey(),
maxAt: timestamptz('max_at').notNull(),
endAt: timestamptz('end_at').notNull(),
/** Degrees above the horizon. */
maxEl: real('max_el').notNull(),
/** Azimuths in degrees from north. */
startAz: real('start_az').notNull(),
maxAz: real('max_az').notNull(),
endAz: real('end_az').notNull(),
/** The ISS is sunlit while the observer's sky is dark at max (naked-eye visible). */
sunlit: boolean('sunlit').notNull().default(false),
computedAt: timestamptz('computed_at').notNull().defaultNow()
},
(t) => [index('iss_passes_end_idx').on(t.endAt)]
);
/** The TLE used for the passes (one row). */
export const radioIssTle = radioSchema.table(
'iss_tle',
{
id: smallint('id').primaryKey().default(1),
name: text('name').notNull(),
line1: text('line1').notNull(),
line2: text('line2').notNull(),
epoch: timestamptz('epoch').notNull(),
fetchedAt: timestamptz('fetched_at').notNull().defaultNow()
},
(t) => [check('iss_tle_single_check', sql`${t.id} = 1`)]
);
/** Listening log. */
export const radioLog = radioSchema.table(
'log',
{
id: id(),
heardAt: timestamptz('heard_at').notNull().defaultNow(),
frequencyId: integer('frequency_id').references(() => radioFrequencies.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
/** What was tuned (kept even if the frequency row is deleted, or for one-offs). */
freqHz: bigint('freq_hz', { mode: 'number' }),
mode: text('mode'),
what: text('what').notNull(),
/** 1 (barely) .. 5 (loud and clear). */
signal: smallint('signal'),
notes: text('notes'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [
index('log_heard_idx').on(t.heardAt),
index('log_frequency_idx').on(t.frequencyId),
check('log_signal_check', sql`${t.signal} BETWEEN 1 AND 5`)
]
);
/** Reminders already sent (kind 'net' + net id, or 'iss' + 0), so each fires once. */
export const radioReminders = radioSchema.table(
'reminders',
{
kind: text('kind').notNull(),
refId: integer('ref_id').notNull(),
at: timestamptz('at').notNull(),
sentAt: timestamptz('sent_at').notNull().defaultNow()
},
(t) => [
primaryKey({ columns: [t.kind, t.refId, t.at] }),
check('reminders_kind_check', sql`${t.kind} IN ('net','iss')`)
]
);