add /freq command - allows lookup of data regarding ham bands for region-specific details
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)):
|
||||
|
||||
+182
-11
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user