All checks were successful
Deploy production / deploy (push) Successful in 1m26s
187 lines
6.1 KiB
TypeScript
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')`)
|
|
]
|
|
);
|