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
All checks were successful
Deploy production / deploy (push) Successful in 1m26s
This commit is contained in:
@@ -35,3 +35,4 @@ export * from './osu-goals';
|
||||
export * from './notes';
|
||||
export * from './math';
|
||||
export * from './plant';
|
||||
export * from './radio';
|
||||
|
||||
186
src/lib/server/db/schema/radio.ts
Normal file
186
src/lib/server/db/schema/radio.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
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')`)
|
||||
]
|
||||
);
|
||||
@@ -31,3 +31,4 @@ export const worldtodoSchema = pgSchema('worldtodo');
|
||||
export const notesSchema = pgSchema('notes');
|
||||
export const mathSchema = pgSchema('math');
|
||||
export const plantSchema = pgSchema('plant');
|
||||
export const radioSchema = pgSchema('radio');
|
||||
|
||||
Reference in New Issue
Block a user