From c056b1b67abb55151ec5811ccbb2ff686c2947b7 Mon Sep 17 00:00:00 2001 From: DcruBro Date: Mon, 31 Aug 2026 20:15:06 +0200 Subject: [PATCH] add /freq command - allows lookup of data regarding ham bands for region-specific details --- include/hammy/command.h | 2 +- sqlite/03-bands.sql | 158 +++++++++++++++++++------------- sqlite/audit.py | 34 +++++++ src/commands/freq.c | 197 +++++++++++++++++++++++++++++++++++++--- src/commands/morse.c | 2 +- src/commands/ping.c | 2 +- src/hammy/command.c | 6 +- src/hammy/refdb.c | 134 +++++++++++++++++++++++---- 8 files changed, 435 insertions(+), 100 deletions(-) diff --git a/include/hammy/command.h b/include/hammy/command.h index 3adf3bc..19ecacc 100644 --- a/include/hammy/command.h +++ b/include/hammy/command.h @@ -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 diff --git a/sqlite/03-bands.sql b/sqlite/03-bands.sql index a3a79be..e73bed2 100644 --- a/sqlite/03-bands.sql +++ b/sqlite/03-bands.sql @@ -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. diff --git a/sqlite/audit.py b/sqlite/audit.py index e8992b5..f561367 100755 --- a/sqlite/audit.py +++ b/sqlite/audit.py @@ -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)): diff --git a/src/commands/freq.c b/src/commands/freq.c index dd5a98c..3458bcb 100644 --- a/src/commands/freq.c +++ b/src/commands/freq.c @@ -4,30 +4,201 @@ #include #include #include +#include #include #include #include +#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; + } + + 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; } - uint64_t freqPlain = (uint64_t)freqMHz * 1000000; // Convert to raw Hz + 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); } diff --git a/src/commands/morse.c b/src/commands/morse.c index fc4d14b..d249d5f 100644 --- a/src/commands/morse.c +++ b/src/commands/morse.c @@ -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) { diff --git a/src/commands/ping.c b/src/commands/ping.c index 31374a0..e2797a8 100644 --- a/src/commands/ping.c +++ b/src/commands/ping.c @@ -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. diff --git a/src/hammy/command.c b/src/hammy/command.c index 97acf39..098f0f4 100644 --- a/src/hammy/command.c +++ b/src/hammy/command.c @@ -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[] = { diff --git a/src/hammy/refdb.c b/src/hammy/refdb.c index c830a9f..70271cb 100644 --- a/src/hammy/refdb.c +++ b/src/hammy/refdb.c @@ -1,11 +1,12 @@ #include +#include #include #include #include // 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); } @@ -265,6 +349,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. @@ -477,6 +567,12 @@ static bool step_bool(sqlite3_stmt* st) { 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; @@ -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;