add /freq command - allows lookup of data regarding ham bands for region-specific details

This commit is contained in:
2026-08-31 20:15:06 +02:00
parent 7acb2aabd4
commit c056b1b67a
8 changed files with 435 additions and 100 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
// or discord_edit_original_interaction_response().
//
// The handler does NOT own the job and must not destroy it.
typedef void (*hammy_command_fn)(const hammy_job_t* job, struct discord* client, const hammy_refdb_t* refdb);
typedef void (*hammy_command_fn)(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
// Plain old data. Every field points at static storage, so this struct owns
// nothing, copies freely, and needs no destructor - the vector can be created
+96 -62
View File
@@ -37,22 +37,105 @@ INSERT INTO license_classes (id, country, code, name, rank, notes) VALUES
-- US band segments (47 CFR Part 97)
--
-- VERIFY BEFORE SHIPPING. Hand-entered from the Part 97 privilege tables.
-- Wrong edges or wrong privileges can put an operator outside their licence.
--
-- Two shapes are used here, deliberately:
--
-- 1. Bands where every eligible class gets the SAME range are generated with
-- INSERT..SELECT over license_classes and a rank threshold. The rule is
-- stated once, in the WHERE clause, and the rows follow from it.
--
-- 2. Bands where classes get GENUINELY DIFFERENT ranges (most of HF: Extra
-- has 14.000-14.150 where General has 14.025-14.150) are written out
-- explicitly, one row per class. A rank threshold cannot express that.
--
-- Shape 1 exists because hand-enumerating six bands times five classes is
-- thirty near-identical rows, and the first version of this file missed several
-- of them - Advanced was absent from 2200m, 630m and 60m entirely, and VHF/UHF
-- was seeded for Technician only, so an Extra was told they had no 2m access.
-- audit.py's privilege-nesting check catches that class of mistake now, but not
-- making it is better.
-- ---------------------------------------------------------------------------
-- 2200m and 630m: all classes, strict EIRP limits, notification required
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(1, 'US', 1, 135700, 137800, 'CW,DATA', NULL, '1 W EIRP maximum'),
(1, 'US', 3, 135700, 137800, 'CW,DATA', NULL, '1 W EIRP maximum'),
(1, 'US', 4, 135700, 137800, 'CW,DATA', NULL, '1 W EIRP maximum'),
(2, 'US', 1, 472000, 479000, 'CW,DATA', NULL, '5 W EIRP maximum'),
(2, 'US', 3, 472000, 479000, 'CW,DATA', NULL, '5 W EIRP maximum'),
(2, 'US', 4, 472000, 479000, 'CW,DATA', NULL, '5 W EIRP maximum');
-- 2200m and 630m: all classes. Strict EIRP limits, and the operator must notify
-- UTC and await a response before transmitting.
-- VERIFY: whether Novice holds these is the part I am least sure of.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, v.modes, v.max_power_w, v.notes
FROM license_classes lc
JOIN (
SELECT 1 AS band_id, 135700 AS low_hz, 137800 AS high_hz,
'CW,DATA' AS modes, NULL AS max_power_w, '1 W EIRP maximum' AS notes
UNION SELECT 2, 472000, 479000, 'CW,DATA', NULL, '5 W EIRP maximum'
) v
WHERE lc.country = 'US';
-- 160m: General and above, full band
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(3, 'US', 1, 1800000, 2000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL),
(3, 'US', 2, 1800000, 2000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL),
(3, 'US', 3, 1800000, 2000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL);
-- 160m, 30m, 17m, 12m: General and above (rank >= 30), whole band, same for all.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, v.modes, v.max_power_w, v.notes
FROM license_classes lc
JOIN (
SELECT 3 AS band_id, 1800000 AS low_hz, 2000000 AS high_hz,
'CW,DATA,PHONE,IMAGE' AS modes, NULL AS max_power_w, NULL AS notes
UNION SELECT 7, 10100000, 10150000, 'CW,DATA', 200, 'No phone or image permitted'
UNION SELECT 9, 18068000, 18110000, 'CW,DATA', NULL, NULL
UNION SELECT 9, 18110000, 18168000, 'PHONE,IMAGE', NULL, NULL
UNION SELECT 11, 24890000, 24930000, 'CW,DATA', NULL, NULL
UNION SELECT 11, 24930000, 24990000, 'PHONE,IMAGE', NULL, NULL
) v
WHERE lc.country = 'US' AND lc.rank >= 30;
-- 60m: five discrete channels, General and above. Stored as channel-width
-- ranges; the USB dial frequency is the channel centre minus 1.5 kHz.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
SELECT 5, 'US', lc.id, v.low_hz, v.high_hz, 'CW,DATA,PHONE', NULL, v.notes
FROM license_classes lc
JOIN (
SELECT 5330500 AS low_hz, 5333300 AS high_hz, 'Channel 1, 100 W ERP, USB' AS notes
UNION SELECT 5346500, 5349300, 'Channel 2, 100 W ERP, USB'
UNION SELECT 5357000, 5359800, 'Channel 3, 100 W ERP, USB'
UNION SELECT 5371500, 5374300, 'Channel 4, 100 W ERP, USB'
UNION SELECT 5403500, 5406300, 'Channel 5, 100 W ERP, USB'
) v
WHERE lc.country = 'US' AND lc.rank >= 30;
-- VHF/UHF and above: Technician and higher (rank >= 20) hold the full
-- allocation. There is no per-class variation above 50 MHz except for Novice.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, v.modes, v.max_power_w, v.notes
FROM license_classes lc
JOIN (
SELECT 13 AS band_id, 50000000 AS low_hz, 50100000 AS high_hz,
'CW' AS modes, NULL AS max_power_w, 'CW only below 50.1 MHz' AS notes
UNION SELECT 13, 50100000, 54000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
UNION SELECT 14, 144000000, 144100000, 'CW', NULL, 'CW only below 144.1 MHz'
UNION SELECT 14, 144100000, 148000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
UNION SELECT 15, 222000000, 225000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
UNION SELECT 16, 420000000, 450000000, 'CW,DATA,PHONE,IMAGE', NULL,
'Geographic restrictions apply near some radar sites'
UNION SELECT 17, 902000000, 928000000, 'CW,DATA,PHONE,IMAGE', NULL,
'Secondary allocation, shared with Part 15 devices'
UNION SELECT 18, 1240000000, 1300000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
) v
WHERE lc.country = 'US' AND lc.rank >= 20;
-- Novice above 50 MHz: two reduced-power slices and nothing else.
-- VERIFY: Novice licences have not been issued since 2000, so an error here
-- would go unnoticed for years.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, 'CW,DATA,PHONE,IMAGE', v.max_power_w,
'Novice segment, reduced power'
FROM license_classes lc
JOIN (
SELECT 15 AS band_id, 222000000 AS low_hz, 225000000 AS high_hz, 25 AS max_power_w
UNION SELECT 18, 1270000000, 1295000000, 5
) v
WHERE lc.country = 'US' AND lc.code = 'N';
-- ---------------------------------------------------------------------------
-- Bands where classes get genuinely different ranges. Written out explicitly:
-- a rank threshold cannot express "Extra from 3.500, General from 3.525".
-- ---------------------------------------------------------------------------
-- 80m / 75m
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
@@ -65,20 +148,6 @@ INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, m
(4, 'US', 4, 3525000, 3600000, 'CW', 200, 'CW only'),
(4, 'US', 5, 3525000, 3600000, 'CW', 200, 'CW only');
-- 60m: five discrete channels, General and above.
-- Stored as channel-width ranges; centre frequency is the USB dial + 1.5 kHz.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(5, 'US', 1, 5330500, 5333300, 'CW,DATA,PHONE', NULL, 'Channel 1, 100 W ERP, USB'),
(5, 'US', 1, 5346500, 5349300, 'CW,DATA,PHONE', NULL, 'Channel 2, 100 W ERP, USB'),
(5, 'US', 1, 5357000, 5359800, 'CW,DATA,PHONE', NULL, 'Channel 3, 100 W ERP, USB'),
(5, 'US', 1, 5371500, 5374300, 'CW,DATA,PHONE', NULL, 'Channel 4, 100 W ERP, USB'),
(5, 'US', 1, 5403500, 5406300, 'CW,DATA,PHONE', NULL, 'Channel 5, 100 W ERP, USB'),
(5, 'US', 3, 5330500, 5333300, 'CW,DATA,PHONE', NULL, 'Channel 1, 100 W ERP, USB'),
(5, 'US', 3, 5346500, 5349300, 'CW,DATA,PHONE', NULL, 'Channel 2, 100 W ERP, USB'),
(5, 'US', 3, 5357000, 5359800, 'CW,DATA,PHONE', NULL, 'Channel 3, 100 W ERP, USB'),
(5, 'US', 3, 5371500, 5374300, 'CW,DATA,PHONE', NULL, 'Channel 4, 100 W ERP, USB'),
(5, 'US', 3, 5403500, 5406300, 'CW,DATA,PHONE', NULL, 'Channel 5, 100 W ERP, USB');
-- 40m
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(6, 'US', 1, 7000000, 7125000, 'CW,DATA', NULL, NULL),
@@ -90,12 +159,6 @@ INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, m
(6, 'US', 4, 7025000, 7125000, 'CW', 200, 'CW only'),
(6, 'US', 5, 7025000, 7125000, 'CW', 200, 'CW only');
-- 30m: no phone or image anywhere, 200 W limit
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(7, 'US', 1, 10100000, 10150000, 'CW,DATA', 200, 'No phone or image permitted'),
(7, 'US', 2, 10100000, 10150000, 'CW,DATA', 200, 'No phone or image permitted'),
(7, 'US', 3, 10100000, 10150000, 'CW,DATA', 200, 'No phone or image permitted');
-- 20m
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(8, 'US', 1, 14000000, 14150000, 'CW,DATA', NULL, NULL),
@@ -105,15 +168,6 @@ INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, m
(8, 'US', 3, 14025000, 14150000, 'CW,DATA', NULL, NULL),
(8, 'US', 3, 14225000, 14350000, 'PHONE,IMAGE', NULL, NULL);
-- 17m
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(9, 'US', 1, 18068000, 18110000, 'CW,DATA', NULL, NULL),
(9, 'US', 1, 18110000, 18168000, 'PHONE,IMAGE', NULL, NULL),
(9, 'US', 2, 18068000, 18110000, 'CW,DATA', NULL, NULL),
(9, 'US', 2, 18110000, 18168000, 'PHONE,IMAGE', NULL, NULL),
(9, 'US', 3, 18068000, 18110000, 'CW,DATA', NULL, NULL),
(9, 'US', 3, 18110000, 18168000, 'PHONE,IMAGE', NULL, NULL);
-- 15m
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(10, 'US', 1, 21000000, 21200000, 'CW,DATA', NULL, NULL),
@@ -125,15 +179,6 @@ INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, m
(10, 'US', 4, 21025000, 21200000, 'CW', 200, 'CW only'),
(10, 'US', 5, 21025000, 21200000, 'CW', 200, 'CW only');
-- 12m
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(11, 'US', 1, 24890000, 24930000, 'CW,DATA', NULL, NULL),
(11, 'US', 1, 24930000, 24990000, 'PHONE,IMAGE', NULL, NULL),
(11, 'US', 2, 24890000, 24930000, 'CW,DATA', NULL, NULL),
(11, 'US', 2, 24930000, 24990000, 'PHONE,IMAGE', NULL, NULL),
(11, 'US', 3, 24890000, 24930000, 'CW,DATA', NULL, NULL),
(11, 'US', 3, 24930000, 24990000, 'PHONE,IMAGE', NULL, NULL);
-- 10m: the only HF band with Technician phone privileges
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(12, 'US', 1, 28000000, 28300000, 'CW,DATA', NULL, NULL),
@@ -147,17 +192,6 @@ INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, m
(12, 'US', 5, 28100000, 28300000, 'CW,DATA', 200, NULL),
(12, 'US', 5, 28300000, 28500000, 'PHONE', 200, NULL);
-- VHF/UHF and above: Technician and higher, full allocations
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(13, 'US', 4, 50000000, 50100000, 'CW', NULL, 'CW only below 50.1 MHz'),
(13, 'US', 4, 50100000, 54000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL),
(14, 'US', 4, 144000000, 144100000, 'CW', NULL, 'CW only below 144.1 MHz'),
(14, 'US', 4, 144100000, 148000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL),
(15, 'US', 4, 222000000, 225000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL),
(16, 'US', 4, 420000000, 450000000, 'CW,DATA,PHONE,IMAGE', NULL, 'Geographic restrictions apply near some radar sites'),
(17, 'US', 4, 902000000, 928000000, 'CW,DATA,PHONE,IMAGE', NULL, 'Secondary allocation, shared with Part 15 devices'),
(18, 'US', 4, 1240000000, 1300000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL);
-- ---------------------------------------------------------------------------
-- IARU allocation extents (class_id NULL = the allocation, not privileges)
-- Partial. Enough to answer "is 7.250 legal here" for Region 1 vs 2.
+34
View File
@@ -186,6 +186,40 @@ def check_domain(db, verbose):
elif verbose:
print(" band_segments no overlaps".ljust(66) + "ok")
# Privilege nesting. In most licensing regimes a higher class holds a
# superset of a lower one, so a segment that a junior class has and a
# senior class does not is nearly always a missing row rather than a
# real rule. This is exactly the shape of bug that reads as plausible
# output - "Extra: not permitted on 2m" looks like data, not an error.
#
# A warning, not a failure: the superset assumption is true of the US
# and most countries, but it is an assumption, and a contributed band
# plan could legitimately break it.
rows = db.execute("""
SELECT DISTINCT hi.code, lo.code, b.name, s.low_hz, s.high_hz
FROM band_segments s
JOIN license_classes lo ON lo.id = s.class_id
JOIN license_classes hi ON hi.country = lo.country AND hi.rank > lo.rank
JOIN bands b ON b.id = s.band_id
WHERE s.country = lo.country
AND NOT EXISTS (
SELECT 1 FROM band_segments t
WHERE t.country = s.country
AND t.class_id = hi.id
AND t.low_hz <= s.low_hz
AND s.high_hz <= t.high_hz)
ORDER BY b.name, hi.rank DESC""").fetchall()
if rows:
for r in rows[:8]:
warn("band_segments",
f"{r[0]} lacks {r[2]} {r[3]/1e6:.3f}-{r[4]/1e6:.3f} MHz "
f"that {r[1]} holds - missing row?")
if len(rows) > 8:
warn("band_segments", f"...and {len(rows) - 8} more nesting gaps")
elif verbose:
print(" band_segments privileges nest by rank".ljust(66) + "ok")
if "ncdxf_beacons" in present:
slots = [r[0] for r in db.execute("SELECT slot_index FROM ncdxf_beacons ORDER BY slot_index")]
if slots != list(range(18)):
+183 -12
View File
@@ -4,30 +4,201 @@
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdarg.h>
#include <hammy/command.h>
#include <hammy/job.h>
#include <hammy/refdb.h>
#define HAMMY_FREQ_BODY_MAX 3600
// Small append-only string builder. Every write is bounds-checked, so a country
// with thirty licence classes overflows into "truncated" rather than the stack.
typedef struct {
char* buf;
size_t cap;
size_t len;
bool overflow;
} hammy_sb_t;
static void sb_addf(hammy_sb_t* sb, const char* fmt, ...) {
if (sb->overflow || sb->len + 1 >= sb->cap) {
sb->overflow = true;
return;
}
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(sb->buf + sb->len, sb->cap - sb->len, fmt, ap);
va_end(ap);
if (n < 0) {
sb->overflow = true;
return;
}
if ((size_t)n >= sb->cap - sb->len) {
sb->len = sb->cap - 1;
sb->overflow = true;
return;
}
sb->len += (size_t)n;
}
// Hz -> MHz with three decimals, which is the resolution every band edge in the
// bundle actually uses. Integer maths so no rounding surprises at an edge.
static void fmt_mhz(int64_t hz, char* out, size_t cap) {
int64_t whole = hz / 1000000;
int64_t frac = (hz % 1000000) / 1000;
snprintf(out, cap, "%" PRId64 ".%03" PRId64, whole, frac);
}
// 'CW,DATA' reads better as 'CW, DATA' in an embed.
static void fmt_modes(const char* modes, char* out, size_t cap) {
size_t j = 0;
for (size_t i = 0; modes[i] && j + 2 < cap; i++) {
out[j++] = modes[i];
if (modes[i] == ',' && j + 1 < cap) { out[j++] = ' '; }
}
out[j] = '\0';
}
// Instant command: runs on the gateway thread, sends a fresh response.
void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, const hammy_refdb_t* refdb) {
// Get the text argument from the job
const char* freq = hammy_job_get_arg(job, "frequency");
if (!freq) {
void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
if (!refdb) {
hammy_job_respond(job, client, "Error", "Reference data is unavailable. Please try again later.", true);
return;
}
const char* arg = hammy_job_get_arg(job, "frequency");
if (!arg || !*arg) {
hammy_job_respond(job, client, "Error", "No frequency provided for lookup.", true);
return;
}
char* endPtr;
float freqMHz = strtof(freq, &endPtr);
if (*endPtr != '\0') {
// Partial conversion, just error out
hammy_job_respond(job, client, "Error", "An internal error occurred. Please try again.", true);
// Parses "14.150", "14150 kHz", "146.52 MHz". Integer maths throughout: a
// bare (int64_t)cast of a float truncates BEFORE the multiply and turns
// 14.150 into 14.000, which silently reports the wrong segment.
int64_t freqHz = 0;
if (!hammy_freq_parse(arg, &freqHz)) {
hammy_job_respond(job, client, "Error", "I could not read that frequency. Try something like `14.150`, `7025 kHz` or `146.52 MHz`.", true);
return;
}
uint64_t freqPlain = (uint64_t)freqMHz * 1000000; // Convert to raw Hz
const char* cc = hammy_job_get_arg(job, "country"); // NULL -> refdb uses US
hammy_freq_t r;
if (!hammy_refdb_freq(refdb, freqHz, cc, &r)) {
hammy_job_respond(job, client, "Error", "Something went wrong looking that up.", true);
return;
}
char freqStr[32];
fmt_mhz(r.freqHz, freqStr, sizeof(freqStr));
char body[HAMMY_FREQ_BODY_MAX];
hammy_sb_t sb = { .buf = body, .cap = sizeof(body), .len = 0, .overflow = false };
body[0] = '\0';
// Not a valid ham band
if (!r.inBand) {
char lo[32], hi[32], dist[32];
fmt_mhz(r.nearestLowHz, lo, sizeof(lo));
fmt_mhz(r.nearestHighHz, hi, sizeof(hi));
fmt_mhz(r.nearestDistanceHz, dist, sizeof(dist));
sb_addf(&sb, "**%s MHz** is not in a valid amateur band.\n\n", freqStr);
sb_addf(&sb, "Nearest is **%s** (%s - %s MHz), %s MHz away.", r.nearestBand, lo, hi, dist);
hammy_job_respond(job, client, "Not an Amateur Band!", body, false);
return;
}
char bandLo[32], bandHi[32];
fmt_mhz(r.bandLowHz, bandLo, sizeof(bandLo));
fmt_mhz(r.bandHighHz, bandHi, sizeof(bandHi));
char title[128];
snprintf(title, sizeof(title), "Band **%s** (%s - %s MHz)\n", r.band, bandLo, bandHi);
// Privileges
if (!r.countryKnown) {
// The common case - only US data is seeded so far. Say what is missing
// and what does exist, rather than showing an empty table.
char codes[HAMMY_FREQ_COUNTRIES_MAX][HAMMY_COUNTRY_MAX];
size_t n = hammy_refdb_countries(refdb, codes, HAMMY_FREQ_COUNTRIES_MAX);
sb_addf(&sb, "\nNo licence data for **%s** in this bundle yet.\n", r.country);
if (n) {
sb_addf(&sb, "Currently available: ");
for (size_t i = 0; i < n; i++) {
sb_addf(&sb, "%s`%s`", i ? ", " : "", codes[i]);
}
sb_addf(&sb, "\n");
}
sb_addf(&sb, "Band plans are contributed by operators - if you know your regulator's allocations, please help fill this in.\n");
} else if (r.nPrivs == 0) {
sb_addf(&sb, "\nNo license classes are recorded for **%s**.\n", r.country);
} else {
sb_addf(&sb, "\n**Privileges in %s**\n", r.country);
for (size_t i = 0; i < r.nPrivs; i++) {
const hammy_freq_priv_t* p = &r.privs[i];
if (!p->permitted) {
sb_addf(&sb, "`%-14s` not permitted here\n", p->name);
continue;
}
char lo[32], hi[32], modes[80];
fmt_mhz(p->segLowHz, lo, sizeof(lo));
fmt_mhz(p->segHighHz, hi, sizeof(hi));
fmt_modes(p->modes, modes, sizeof(modes));
sb_addf(&sb, "`%-14s` %s (%s - %s", p->name, modes, lo, hi);
if (p->maxPowerW > 0) {
sb_addf(&sb, ", max %d W", p->maxPowerW);
}
sb_addf(&sb, ")\n");
if (p->notes[0]) {
sb_addf(&sb, " *%s*\n", p->notes);
}
}
}
// IARU
if (r.nIaru) {
sb_addf(&sb, "\n**IARU Allocation**\n");
for (size_t i = 0; i < r.nIaru; i++) {
char lo[32], hi[32];
fmt_mhz(r.iaru[i].lowHz, lo, sizeof(lo));
fmt_mhz(r.iaru[i].highHz, hi, sizeof(hi));
sb_addf(&sb, "Region %d: %s - %s MHz\n", r.iaru[i].region, lo, hi);
}
}
// Boundary warnings
if (r.atBandEdge) {
sb_addf(&sb, "\n> This is exactly the band edge. A signal of any width centred here extends outside the band.\n");
} else if (r.atSegmentEdge) {
sb_addf(&sb, "\n> This is exactly a segment boundary, so a signal centred here straddles both sides.\n");
}
if (sb.overflow) {
snprintf(body + sizeof(body) - 20, 20, "\n... truncated");
}
hammy_job_respond(job, client, title, body, false);
}
+1 -1
View File
@@ -71,7 +71,7 @@ static size_t morse_utf8_trim(const char* s, size_t max) {
}
// Instant command: runs on the gateway thread, sends a fresh response.
void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, const hammy_refdb_t* refdb) {
void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
// Get the text argument from the job
const char* text = hammy_job_get_arg(job, "text");
if (!text) {
+1 -1
View File
@@ -13,7 +13,7 @@ static int64_t hammy_snowflake_to_ms(u64snowflake id) {
}
// Instant command: runs on the gateway thread, sends a fresh response.
void hammy_cmd_ping(const hammy_job_t* job, struct discord* client, const hammy_refdb_t* refdb) {
void hammy_cmd_ping(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
(void)refdb;
// Gateway heartbeat round trip, as measured by Concord.
+3 -3
View File
@@ -6,9 +6,9 @@
// Handlers live in src/hammy/commands/. Declared here rather than in a header
// so adding a command touches exactly two places: its own .c file and this
// table.
void hammy_cmd_ping(const hammy_job_t* job, struct discord* client, const hammy_refdb_t* refdb);
void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, const hammy_refdb_t* refdb);
void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, const hammy_refdb_t* refdb);
void hammy_cmd_ping(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
// TODO: Temporary - move to a proper options system; e.g. a vector of heap-allocated options or something. For now, just point at static storage.
static struct discord_application_command_option morse_opts[] = {
+115 -19
View File
@@ -1,11 +1,12 @@
#include <concord/log.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include <hammy/refdb.h>
// Whole-callsign rules (cty.dat's "=CALL" entries) must match the entire string.
static const char* SQL_EXACT =
static const char SQL_EXACT[] =
"SELECT e.id, e.name, e.continent,"
" COALESCE(p.cq_zone, e.cq_zone), COALESCE(p.itu_zone, e.itu_zone),"
" e.latitude, e.longitude, e.utc_offset, p.prefix"
@@ -14,7 +15,7 @@ static const char* SQL_EXACT =
" LIMIT 1";
// One candidate at a time, called longest-first by the caller.
static const char* SQL_PREFIX =
static const char SQL_PREFIX[] =
"SELECT e.id, e.name, e.continent,"
" COALESCE(p.cq_zone, e.cq_zone), COALESCE(p.itu_zone, e.itu_zone),"
" e.latitude, e.longitude, e.utc_offset, p.prefix"
@@ -22,10 +23,10 @@ static const char* SQL_PREFIX =
" WHERE p.prefix = ?1 AND p.exact = 0"
" LIMIT 1";
static const char* SQL_VERSION =
static const char SQL_VERSION[] =
"SELECT value FROM ref_meta WHERE key = 'bundle_version'";
static const char* SQL_MORSE =
static const char SQL_MORSE[] =
"SELECT code FROM morse WHERE character = UPPER(?1) LIMIT 1";
// Band plus per-class privileges in one pass.
@@ -39,7 +40,7 @@ static const char* SQL_MORSE =
// band_segments half-open [low, high). 14.150 is the START of the phone
// segment, not the end of the CW one. BETWEEN would match
// both and the command would print contradictory modes.
static const char* SQL_FREQ_MAIN =
static const char SQL_FREQ_MAIN[] =
"SELECT b.name, b.edge_low_hz, b.edge_high_hz,"
" lc.code, lc.name, lc.rank,"
" s.modes, s.low_hz, s.high_hz, s.max_power_w, s.notes"
@@ -58,7 +59,7 @@ static const char* SQL_FREQ_MAIN =
// Regional allocation, independent of national licensing. country = '' and
// class_id IS NULL mark these rows: they say what the band IS in a region, not
// who may use it.
static const char* SQL_FREQ_IARU =
static const char SQL_FREQ_IARU[] =
"SELECT s.iaru_region, s.low_hz, s.high_hz, s.modes"
" FROM band_segments s"
" WHERE s.country = '' AND s.class_id IS NULL"
@@ -66,23 +67,25 @@ static const char* SQL_FREQ_IARU =
" ORDER BY s.iaru_region";
// min() with two arguments is SQLite's scalar min, not the aggregate.
static const char* SQL_FREQ_NEAREST =
static const char SQL_FREQ_NEAREST[] =
"SELECT name, edge_low_hz, edge_high_hz,"
" min(abs(edge_low_hz - ?1), abs(edge_high_hz - ?1))"
" FROM bands ORDER BY 4 LIMIT 1";
// Is the frequency exactly on a segment boundary for this country?
static const char* SQL_FREQ_SEG_EDGE =
static const char SQL_FREQ_SEG_EDGE[] =
"SELECT 1 FROM band_segments"
" WHERE country = ?2 AND (low_hz = ?1 OR high_hz = ?1) LIMIT 1";
static const char* SQL_countryKnown =
"SELECT 1 FROM license_classes WHERE country = ?1 LIMIT 1";
static const char* SQL_COUNTRY_LIST =
static const char SQL_COUNTRY_LIST[] =
"SELECT DISTINCT country FROM license_classes"
" WHERE country <> '' ORDER BY country";
// Does the bundle carry licence data for this country at all? Only US is seeded
// so far, so "no data yet" is the common answer and needs saying properly.
static const char SQL_COUNTRY_KNOWN[] =
"SELECT 1 FROM license_classes WHERE country = ?1 LIMIT 1";
// Authorizer: the connection may read, and nothing else.
//
// SQLITE_OPEN_READONLY protects the MAIN database file. It does not stop
@@ -108,9 +111,70 @@ static int hammy_refdb_authorizer(void* unused, int action,
}
}
// Every prepared statement is registered in HAMMY_STATEMENTS below, exactly
// once. open() and close() both walk that table, so adding a statement means
// adding one row - not a struct field plus a prepare call plus a finalize call,
// three places that can silently drift apart.
//
// That drift is what produced the stCountryKnown crash: the field and the SQL
// existed, the prepare didn't, and sqlite3_clear_bindings() dereferenced a NULL
// left over from calloc(). Now it's impossible to reach a slot except through
// the table.
// NOTE: the SQL_* constants above are declared `static const char X[]`, not
// `static const char* X`. A pointer variable is not a constant expression, so
// using one in this table's static initializer is a compile error
// ("initializer element is not constant"). An array's address is a link-time
// constant and works. Don't "tidy" them back into pointers.
typedef struct {
const char* name; // for log messages
size_t offset; // into struct hammy_refdb_t
const char* sql;
} hammy_stmt_def_t;
#define HAMMY_STMT(field, sqlConst) \
{ #field, offsetof(struct hammy_refdb_t, field), sqlConst }
static const hammy_stmt_def_t HAMMY_STATEMENTS[] = {
HAMMY_STMT(stExact, SQL_EXACT),
HAMMY_STMT(stPrefix, SQL_PREFIX),
HAMMY_STMT(stMorse, SQL_MORSE),
HAMMY_STMT(stFreqMain, SQL_FREQ_MAIN),
HAMMY_STMT(stFreqIaru, SQL_FREQ_IARU),
HAMMY_STMT(stFreqNearest, SQL_FREQ_NEAREST),
HAMMY_STMT(stFreqSegEdge, SQL_FREQ_SEG_EDGE),
HAMMY_STMT(stCountryKnown, SQL_COUNTRY_KNOWN),
HAMMY_STMT(stCountryList, SQL_COUNTRY_LIST),
};
#define HAMMY_STMT_COUNT (sizeof(HAMMY_STATEMENTS) / sizeof(HAMMY_STATEMENTS[0]))
static sqlite3_stmt** stmt_slot(hammy_refdb_t* db, const hammy_stmt_def_t* def) {
return (sqlite3_stmt**)((char*)db + def->offset);
}
// sqlite3_reset(NULL) is harmless, but sqlite3_clear_bindings(NULL) dereferences
// straight away. Query entry points check their statements before touching them,
// so a half-built refdb returns false instead of taking the process down.
static bool stmts_ready(const char* where, sqlite3_stmt* const* stmts, size_t count) {
for (size_t i = 0; i < count; i++) {
if (!stmts[i]) {
log_error("[refdb] %s called with unprepared statements", where);
return false;
}
}
return true;
}
static bool prepare(sqlite3* h, const char* sql, sqlite3_stmt** out) {
if (sqlite3_prepare_v2(h, sql, -1, out, NULL) != SQLITE_OK) {
log_error("[refdb] prepare failed: %s", sqlite3_errmsg(h));
log_error("[refdb] sql: %s", sql);
// prepare_v2 doesn't reliably clear this on every error path, and a
// garbage pointer would sail past the NULL checks below.
*out = NULL;
return false;
}
@@ -156,9 +220,24 @@ hammy_refdb_t* hammy_refdb_open(const char* path) {
sqlite3_set_authorizer(db->handle, &hammy_refdb_authorizer, NULL);
if (!prepare(db->handle, SQL_EXACT, &db->stExact)) { goto fail; }
if (!prepare(db->handle, SQL_PREFIX, &db->stPrefix)) { goto fail; }
if (!prepare(db->handle, SQL_MORSE, &db->stMorse)) { goto fail; }
for (size_t i = 0; i < HAMMY_STMT_COUNT; i++) {
const hammy_stmt_def_t* def = &HAMMY_STATEMENTS[i];
if (!prepare(db->handle, def->sql, stmt_slot(db, def))) {
log_error("[refdb] statement '%s' failed to prepare", def->name);
goto fail;
}
}
// Belt and braces. A registry that drifts from the struct would otherwise
// leave a NULL slot that crashes inside SQLite on first use, far from the
// cause. Fail here instead, naming the statement.
for (size_t i = 0; i < HAMMY_STMT_COUNT; i++) {
if (!(*stmt_slot(db, &HAMMY_STATEMENTS[i]))) {
log_error("[refdb] statement '%s' is NULL after prepare", HAMMY_STATEMENTS[i].name);
goto fail;
}
}
sqlite3_stmt* st = NULL;
if (prepare(db->handle, SQL_VERSION, &st)) {
@@ -186,9 +265,14 @@ bool hammy_refdb_close(hammy_refdb_t** db) {
// Statements must be finalized before the connection closes, or we're in deep shit
// (sqlite3_close() returns SQLITE_BUSY and the handle leaks)
if (d->stExact) { sqlite3_finalize(d->stExact); }
if (d->stPrefix) { sqlite3_finalize(d->stPrefix); }
if (d->stMorse) { sqlite3_finalize(d->stMorse); }
for (size_t i = 0; i < HAMMY_STMT_COUNT; i++) {
sqlite3_stmt** slot = stmt_slot(d, &HAMMY_STATEMENTS[i]);
if (*slot) {
sqlite3_finalize(*slot);
*slot = NULL;
}
}
if (d->handle) { sqlite3_close(d->handle); }
@@ -266,6 +350,9 @@ static bool search_prefixes(hammy_refdb_t* db, const char* call, hammy_dxcc_t* o
bool hammy_refdb_dxcc(hammy_refdb_t* db, const char* callsign, hammy_dxcc_t* out) {
if (!db || !callsign || !out) { return false; }
sqlite3_stmt* const needed[] = { db->stExact, db->stPrefix };
if (!stmts_ready("dxcc", needed, 2)) { return false; }
char call[HAMMY_CALLSIGN_MAX];
normalise(callsign, call, sizeof(call));
@@ -323,6 +410,9 @@ bool hammy_refdb_dxcc(hammy_refdb_t* db, const char* callsign, hammy_dxcc_t* out
bool hammy_refdb_get_morse(hammy_refdb_t* db, char c, const char** out) {
if (!db || !out) { return false; }
sqlite3_stmt* const needed[] = { db->stMorse };
if (!stmts_ready("morse", needed, 1)) { return false; }
// The table is ASCII (ITU-R M.1677-1 has no non-Latin extensions), and a
// single byte out of a multi-byte UTF-8 sequence is not valid text to bind,
// so anything with the high bit set is a miss without touching SQLite.
@@ -478,6 +568,12 @@ bool hammy_refdb_freq(hammy_refdb_t* db, int64_t freqHz, const char* country,
hammy_freq_t* out) {
if (!db || !out || freqHz <= 0) { return false; }
sqlite3_stmt* const needed[] = {
db->stFreqMain, db->stFreqIaru, db->stFreqNearest,
db->stFreqSegEdge, db->stCountryKnown
};
if (!stmts_ready("freq", needed, 5)) { return false; }
memset(out, 0, sizeof(*out));
out->freqHz = freqHz;
normalise_country(country, out->country, sizeof(out->country));
@@ -582,7 +678,7 @@ bool hammy_refdb_freq(hammy_refdb_t* db, int64_t freqHz, const char* country,
size_t hammy_refdb_countries(hammy_refdb_t* db,
char out[][HAMMY_COUNTRY_MAX], size_t cap) {
if (!db || !out || cap == 0) return 0;
if (!db || !out || cap == 0 || !db->stCountryList) { return 0; }
size_t n = 0;