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
+184 -13
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;
}
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);
}
+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); }
@@ -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;