sql commands for querying the frequency bands and segments

This commit is contained in:
2026-08-31 13:19:50 +02:00
parent 2aa0ae3d9a
commit 7acb2aabd4
6 changed files with 1580 additions and 4 deletions
+112 -2
View File
@@ -4,6 +4,7 @@
#include <stdbool.h>
#include <stddef.h>
#include <sqlite3.h>
#include <stdint.h>
#include <hammy/types.h>
@@ -15,14 +16,30 @@
#define HAMMY_CALLSIGN_MAX 32
#define HAMMY_ENTITY_NAME_MAX 64
#define HAMMY_COUNTRY_MAX 4
// Longest code in the morse table is '$' ('...-..-'), seven characters.
#define HAMMY_MORSE_MAX 16
// A country has at most a handful of licence classes, each with at most a
// couple of segments covering one exact frequency. 24 is generous.
#define HAMMY_FREQ_PRIVS_MAX 24
#define HAMMY_FREQ_IARU_MAX 8
#define HAMMY_FREQ_COUNTRIES_MAX 64
struct hammy_refdb_t {
sqlite3* handle;
sqlite3_stmt* stExact;
sqlite3_stmt* stPrefix;
sqlite3_stmt* stMorse;
sqlite3_stmt* stFreqMain;
sqlite3_stmt* stFreqIaru;
sqlite3_stmt* stFreqNearest;
sqlite3_stmt* stFreqSegEdge;
sqlite3_stmt* stCountryKnown;
sqlite3_stmt* stCountryList;
char morseCode[HAMMY_MORSE_MAX]; // Scratch for the last hammy_refdb_get_morse() hit
char version[64];
};
@@ -39,6 +56,64 @@ struct hammy_dxcc_t {
char matchedPrefix[HAMMY_CALLSIGN_MAX];
bool exact;
};
// ---------------------------------------------------------------------------
// Frequency lookup
// ---------------------------------------------------------------------------
struct hammy_freq_priv_t {
char code[8]; // 'E', 'G', 'T'
char name[48]; // 'Amateur Extra'
int rank;
bool permitted; // false = this class may NOT transmit here
char modes[64]; // 'CW,DATA' - empty when !permitted
int64_t segLowHz;
int64_t segHighHz;
int maxPowerW; // 0 = national default, no specific limit
char notes[160];
};
struct hammy_freq_iaru_t {
int region; // 1, 2 or 3
int64_t lowHz;
int64_t highHz;
char modes[32];
};
struct hammy_freq_t {
int64_t freqHz;
bool inBand;
char band[16]; // '20m'
int64_t bandLowHz;
int64_t bandHighHz;
// The frequency sits exactly on a band or segment boundary. Worth saying
// out loud: a signal of any width centred there straddles both sides.
bool atBandEdge;
bool atSegmentEdge;
// False when the bundle carries no licence data for this country at all.
// The band and IARU results are still valid; only the privilege table is
// missing. Say so rather than showing an empty table.
bool countryKnown;
char country[HAMMY_COUNTRY_MAX];
hammy_freq_priv_t privs[HAMMY_FREQ_PRIVS_MAX];
size_t nPrivs;
hammy_freq_iaru_t iaru[HAMMY_FREQ_IARU_MAX];
size_t nIaru;
// Only meaningful when !in_band.
char nearestBand[16];
int64_t nearestLowHz;
int64_t nearestHighHz;
int64_t nearestDistanceHz;
};
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
// Opens the bundle read-only and prepares the hot statements.
// Returns NULL and logs on failure.
@@ -47,6 +122,13 @@ hammy_refdb_t* hammy_refdb_open(const char* path);
// Finalises statements and closes the connection. NULLs the passing reference.
bool hammy_refdb_close(hammy_refdb_t** db);
// Bundle version string from ref_meta, or NULL. Owned by the refdb.
const char* hammy_refdb_version(hammy_refdb_t* db);
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
// Resolves a callsign to a DXCC entity.
//
// Uses candidate-prefix equality seeks rather than "? GLOB prefix || '*'". The
@@ -63,8 +145,36 @@ bool hammy_refdb_dxcc(hammy_refdb_t* db, const char* callsign, hammy_dxcc_t* out
// On a hit *out points at storage owned by the refdb and is only valid until
// the NEXT call on the same handle - copy it if it has to outlive that.
bool hammy_refdb_get_morse(hammy_refdb_t* db, char c, const char** out);
// What band is freq_hz in, and who may transmit there.
//
// country is an ISO 3166-1 alpha-2 code; NULL or empty means "US". Always
// returns true unless the arguments are bad: "not in a band" and "no data for
// that country" are results, not failures. Check out->in_band and
// out->country_known.
bool hammy_refdb_freq(hammy_refdb_t* db, int64_t freqHz, const char* country,
hammy_freq_t* out);
// Bundle version string from ref_meta, or NULL. Owned by the refdb.
const char* hammy_refdb_version(hammy_refdb_t* db);
// Which countries have licence data, for the "no data for XX yet" message.
// Writes up to cap codes and returns how many were written.
size_t hammy_refdb_countries(hammy_refdb_t* db,
char out[][HAMMY_COUNTRY_MAX], size_t cap);
// ---------------------------------------------------------------------------
// Input parsing
// ---------------------------------------------------------------------------
// Parses a user-supplied frequency into integer Hz. Accepts "14.150",
// "14150 kHz", "146.52 MHz", "1.2 GHz", "14150000 Hz". A bare number with no
// unit is read as MHz, which is what people type.
//
// Deliberately avoids floating point: "14.150" is converted digit by digit to
// 14150000 exactly. Going via double gives 14150000.000000002, which compares
// wrong against an integer column at exactly a band edge - the one case where
// being right matters most.
//
// Returns false on unparseable input or a value outside 1 Hz .. 300 GHz.
bool hammy_freq_parse(const char* text, int64_t* outHz);
#endif
+3
View File
@@ -8,5 +8,8 @@ typedef struct hammy_command_t hammy_command_t;
typedef struct hammy_bot_t hammy_bot_t;
typedef struct hammy_refdb_t hammy_refdb_t;
typedef struct hammy_dxcc_t hammy_dxcc_t;
typedef struct hammy_freq_priv_t hammy_freq_priv_t;
typedef struct hammy_freq_iaru_t hammy_freq_iaru_t;
typedef struct hammy_freq_t hammy_freq_t;
#endif
+33
View File
@@ -0,0 +1,33 @@
#include <concord/discord.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <hammy/command.h>
#include <hammy/job.h>
#include <hammy/refdb.h>
// 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) {
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);
return;
}
uint64_t freqPlain = (uint64_t)freqMHz * 1000000; // Convert to raw Hz
}
+14
View File
@@ -8,6 +8,7 @@
// 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);
// 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[] = {
@@ -20,6 +21,18 @@ static struct discord_application_command_options morse_opts_struct = {
.array = morse_opts
};
static struct discord_application_command_option freq_opts[] = {
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = "frequency",
.description = "Frequency in MHz", .required = true },
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = "country",
.description = "Country code to look up (e.g. US) - Defaults to US if none specified.", .required = false },
};
static struct discord_application_command_options freq_opts_struct = {
.size = 2,
.array = freq_opts
};
// The command table. Pure data - no allocation, no lifetime, no destructor.
// Copying an entry into the bot's vector is a plain struct copy, and the vector
// may be created with a NULL destructor hook.
@@ -28,6 +41,7 @@ static struct discord_application_command_options morse_opts_struct = {
static const hammy_command_t hammy_builtin_commands[] = {
{ "ping", "Check whether Hammy is alive and how fast it is responding", NULL, &hammy_cmd_ping, true },
{ "morse", "Text to and from Morse code", &morse_opts_struct, &hammy_cmd_morse, true },
{ "freq", "Band, segment and who can transmit", &freq_opts_struct, &hammy_cmd_freq, true }
// Tier 0 commands go here as they land. All pure computation, so instant:
// { "grid", "Maidenhead locator conversions", &grid_opts, &hammy_cmd_grid, true },
+296
View File
@@ -28,6 +28,61 @@ static const char* SQL_VERSION =
static const char* SQL_MORSE =
"SELECT code FROM morse WHERE character = UPPER(?1) LIMIT 1";
// Band plus per-class privileges in one pass.
//
// Both LEFT JOINs matter. They are what makes "Technician: not permitted here"
// appear as a row rather than vanishing - a user excluded from a segment needs
// to be told so, not shown a shorter list.
//
// Boundary convention differs between the tables on purpose:
// bands inclusive at both ends. 14.350 is still "20m".
// 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 =
"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"
" FROM bands b"
" LEFT JOIN license_classes lc"
" ON lc.country = ?2"
" LEFT JOIN band_segments s"
" ON s.band_id = b.id"
" AND s.class_id = lc.id"
" AND s.country = ?2"
" AND s.low_hz <= ?1"
" AND ?1 < s.high_hz"
" WHERE b.edge_low_hz <= ?1 AND ?1 <= b.edge_high_hz"
" ORDER BY lc.rank DESC, s.low_hz";
// 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 =
"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"
" AND s.low_hz <= ?1 AND ?1 < s.high_hz"
" ORDER BY s.iaru_region";
// min() with two arguments is SQLite's scalar min, not the aggregate.
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 =
"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 =
"SELECT DISTINCT country FROM license_classes"
" WHERE country <> '' ORDER BY country";
// Authorizer: the connection may read, and nothing else.
//
// SQLITE_OPEN_READONLY protects the MAIN database file. It does not stop
@@ -300,3 +355,244 @@ bool hammy_refdb_get_morse(hammy_refdb_t* db, char c, const char** out) {
return hit;
}
// ---------------------------------------------------------------------------
// Frequency parsing
// ---------------------------------------------------------------------------
// Copies a TEXT column into a fixed buffer, tolerating NULL.
static void copy_text(sqlite3_stmt* st, int col, char* dst, size_t cap) {
const unsigned char* v = sqlite3_column_text(st, col);
snprintf(dst, cap, "%s", v ? (const char*)v : "");
}
bool hammy_freq_parse(const char* text, int64_t* outHz) {
if (!text || !outHz) { return false; }
while (*text == ' ' || *text == '\t') { text++; }
// Integer part.
int64_t whole = 0;
bool anyDigit = false;
while (*text >= '0' && *text <= '9') {
if (whole > INT64_MAX / 10 - 9) { return false; } // absurd input
whole = whole * 10 + (*text - '0');
anyDigit = true;
text++;
}
// Fractional part, accumulated as digits rather than a double. Six decimal
// places of MHz is exactly 1 Hz, so anything beyond that is discarded.
int64_t frac = 0;
int fracDigits = 0;
if (*text == '.' || *text == ',') {
text++;
while (*text >= '0' && *text <= '9') {
if (fracDigits < 9) {
frac = frac * 10 + (*text - '0');
fracDigits++;
}
anyDigit = true;
text++;
}
}
if (!anyDigit) { return false; }
while (*text == ' ' || *text == '\t') { text++; }
// Unit. A bare number means MHz, which is what people type.
int64_t mult = 1000000; // Hz per unit
if (*text) {
char unit[8] = {0};
size_t i = 0;
while (text[i] && i + 1 < sizeof(unit) && text[i] != ' ') {
char c = text[i];
unit[i] = (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
i++;
}
if (!strcmp(unit, "hz")) { mult = 1; }
else if (!strcmp(unit, "khz") || !strcmp(unit, "k")) { mult = 1000; }
else if (!strcmp(unit, "mhz") || !strcmp(unit, "m")) { mult = 1000000; }
else if (!strcmp(unit, "ghz") || !strcmp(unit, "g")) { mult = 1000000000; }
else { return false; }
}
// Scale the fraction to the unit without ever touching a double.
int64_t scale = 1;
for (int i = 0; i < fracDigits; i++) {
if (scale > INT64_MAX / 10) { return false; }
scale *= 10;
}
if (whole > INT64_MAX / mult) { return false; }
int64_t hz = whole * mult + (frac * mult) / scale;
if (hz <= 0 || hz > 300000000000LL) { return false; } // 1 Hz .. 300 GHz
*outHz = hz;
return true;
}
// ---------------------------------------------------------------------------
// Frequency lookup
// ---------------------------------------------------------------------------
static void normalise_country(const char* in, char* out, size_t cap) {
size_t j = 0;
if (!in || !*in) {
snprintf(out, cap, "US"); // default
return;
}
for (size_t i = 0; in[i] && j + 1 < cap; i++) {
char c = in[i];
if (c >= 'a' && c <= 'z') { c = (char)(c - 'a' + 'A'); }
if (c >= 'A' && c <= 'Z') { out[j++] = c; }
}
out[j] = '\0';
if (!out[0]) { snprintf(out, cap, "US"); }
}
static bool step_bool(sqlite3_stmt* st) {
bool got = (sqlite3_step(st) == SQLITE_ROW);
sqlite3_reset(st);
return got;
}
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; }
memset(out, 0, sizeof(*out));
out->freqHz = freqHz;
normalise_country(country, out->country, sizeof(out->country));
// Does the bundle know this country at all? Only US privileges are seeded so
// far, so this is the common path and the message matters.
sqlite3_reset(db->stCountryKnown);
sqlite3_clear_bindings(db->stCountryKnown);
sqlite3_bind_text(db->stCountryKnown, 1, out->country, -1, SQLITE_TRANSIENT);
out->countryKnown = step_bool(db->stCountryKnown);
// Band and privileges.
sqlite3_stmt* st = db->stFreqMain;
sqlite3_reset(st);
sqlite3_clear_bindings(st);
sqlite3_bind_int64(st, 1, freqHz);
sqlite3_bind_text(st, 2, out->country, -1, SQLITE_TRANSIENT);
while (sqlite3_step(st) == SQLITE_ROW) {
if (!out->inBand) {
out->inBand = true;
copy_text(st, 0, out->band, sizeof(out->band));
out->bandLowHz = sqlite3_column_int64(st, 1);
out->bandHighHz = sqlite3_column_int64(st, 2);
out->atBandEdge = (freqHz == out->bandLowHz ||
freqHz == out->bandHighHz);
}
// A NULL class means the country has no licence classes at all; the row
// still carried the band, which is why it is read above first.
if (sqlite3_column_type(st, 3) == SQLITE_NULL) { continue; }
if (out->nPrivs >= HAMMY_FREQ_PRIVS_MAX) { continue; }
hammy_freq_priv_t* p = &out->privs[out->nPrivs++];
copy_text(st, 3, p->code, sizeof(p->code));
copy_text(st, 4, p->name, sizeof(p->name));
p->rank = sqlite3_column_int(st, 5);
p->permitted = (sqlite3_column_type(st, 6) != SQLITE_NULL);
if (p->permitted) {
copy_text(st, 6, p->modes, sizeof(p->modes));
p->segLowHz = sqlite3_column_int64(st, 7);
p->segHighHz = sqlite3_column_int64(st, 8);
p->maxPowerW = sqlite3_column_int(st, 9); // 0 when NULL
copy_text(st, 10, p->notes, sizeof(p->notes));
}
}
sqlite3_reset(st);
// Not in any band: report the nearest one so the user can see how far off
// they are. Usually a typo - 15.000 instead of 14.150.
if (!out->inBand) {
st = db->stFreqNearest;
sqlite3_reset(st);
sqlite3_clear_bindings(st);
sqlite3_bind_int64(st, 1, freqHz);
if (sqlite3_step(st) == SQLITE_ROW) {
copy_text(st, 0, out->nearestBand, sizeof(out->nearestBand));
out->nearestLowHz = sqlite3_column_int64(st, 1);
out->nearestHighHz = sqlite3_column_int64(st, 2);
out->nearestDistanceHz = sqlite3_column_int64(st, 3);
}
sqlite3_reset(st);
return true;
}
// IARU regional allocations. Independent of country, so worth showing even
// when the privilege table is empty.
st = db->stFreqIaru;
sqlite3_reset(st);
sqlite3_clear_bindings(st);
sqlite3_bind_int64(st, 1, freqHz);
while (sqlite3_step(st) == SQLITE_ROW && out->nIaru < HAMMY_FREQ_IARU_MAX) {
hammy_freq_iaru_t* r = &out->iaru[out->nIaru++];
r->region = sqlite3_column_int(st, 0);
r->lowHz = sqlite3_column_int64(st, 1);
r->highHz = sqlite3_column_int64(st, 2);
copy_text(st, 3, r->modes, sizeof(r->modes));
}
sqlite3_reset(st);
// Sitting exactly on a segment boundary is worth flagging: a signal of any
// width centred there straddles both sides.
st = db->stFreqSegEdge;
sqlite3_reset(st);
sqlite3_clear_bindings(st);
sqlite3_bind_int64(st, 1, freqHz);
sqlite3_bind_text(st, 2, out->country, -1, SQLITE_TRANSIENT);
out->atSegmentEdge = step_bool(st);
return true;
}
size_t hammy_refdb_countries(hammy_refdb_t* db,
char out[][HAMMY_COUNTRY_MAX], size_t cap) {
if (!db || !out || cap == 0) return 0;
size_t n = 0;
sqlite3_reset(db->stCountryList);
while (n < cap && sqlite3_step(db->stCountryList) == SQLITE_ROW) {
copy_text(db->stCountryList, 0, out[n++], HAMMY_COUNTRY_MAX);
}
sqlite3_reset(db->stCountryList);
return n;
}
+1122 -2
View File
File diff suppressed because it is too large Load Diff