Add /dxcc command for converting a callsign into a DXCC entity - optional grid argument calculates distance from

This commit is contained in:
2026-09-03 16:10:13 +02:00
parent c7d2052fac
commit 623d121c05
10 changed files with 444 additions and 5 deletions
+1
View File
@@ -10,6 +10,7 @@ Current features:
- /q <q-code> - Convert Q-Code to Question/Answer.
- /phonetic <text> - Convert Text to Phonetics (useful for callsigns); If text is under 12 characters, also shows pronunciaction.
- /morse <text> - Convert Text to Morse Code.
- /dxcc <callsign> [grid (maidenhead locator)] - Show the DXCC Entity of a Callsign.
- /ping - Ping the Bot.
Currently in-development, many features planned!
+1
View File
@@ -11,5 +11,6 @@ void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, hammy_refdb_
void hammy_cmd_q(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Q-Code lookup
void hammy_cmd_abbr(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Abbreviation lookup
void hammy_cmd_phonetic(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Callsign -> Phonetics
void hammy_cmd_dxcc(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Callsign + Grid -> DXCC
#endif
+43
View File
@@ -0,0 +1,43 @@
#ifndef HAMMY_GEO_H
#define HAMMY_GEO_H
#include <stdbool.h>
#include <stddef.h>
// Maidenhead locators and great-circle maths. No dependencies beyond libm, no
// state, no database - safe to call from any thread.
//
// Lives in its own module rather than utils.h because /grid, /beacon and /sat
// will all want it, and mixing coordinate maths in with string helpers makes
// both harder to find.
// 8 characters plus NUL. Longer locators exist in theory; nothing uses them.
#define HAMMY_GRID_MAX 9
// Writes a Maidenhead locator for the given coordinates. precision must be
// 2 (field), 4 (square), 6 (subsquare) or 8 (extended square); 6 is what people
// quote. Returns false on out-of-range coordinates, bad precision, or too small
// a buffer.
bool hammy_grid_from_latlon(double lat, double lon, int precision, char* out, size_t cap);
// Parses a 2, 4, 6 or 8 character locator. Case-insensitive, ignores embedded
// whitespace, rejects anything longer rather than truncating it.
//
// Returns the CENTER of the square, not its south-west corner: using the corner
// biases every distance by up to half a square, which at 4-character precision
// is about 60 km.
bool hammy_grid_to_latlon(const char* grid, double* outLat, double* outLon);
// Haversine distance in km and initial bearing in degrees true. Either output
// pointer may be NULL.
void hammy_great_circle(double lat1, double lon1, double lat2, double lon2,
double* outDistKm, double* outBearingDeg);
// The other way round the planet.
double hammy_long_path_km(double shortPathKm);
double hammy_reciprocal_bearing(double bearingDeg);
// 16-point compass abbreviation ("NNE"). Points at static storage.
const char* hammy_compass_point(double bearingDeg);
#endif
+1 -1
View File
@@ -111,7 +111,7 @@ struct hammy_freq_t {
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.
// out loud: a signal of any width centerd there straddles both sides.
bool atBandEdge;
bool atSegmentEdge;
+1 -1
View File
@@ -86,7 +86,7 @@ SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, v.modes, v.max_power_w, v.no
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.
-- ranges; the USB dial frequency is the channel center 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
+191
View File
@@ -0,0 +1,191 @@
#include <concord/discord.h>
#include <inttypes.h>
#include <math.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <hammy/command.h>
#include <hammy/commands.h>
#include <hammy/geo.h>
#include <hammy/job.h>
#include <hammy/refdb.h>
#include <hammy/utils.h>
#define HAMMY_DXCC_BODY_MAX 2048
#define HAMMY_DXCC_TITLE_MAX 128
// Formats a signed decimal degree as "38.90 N" / "77.04 W".
static void hammy_dxcc_fmt_coord(double value, bool isLatitude, char* out, size_t cap) {
char hemi = isLatitude ? (value >= 0.0 ? 'N' : 'S')
: (value >= 0.0 ? 'E' : 'W');
snprintf(out, cap, "%.2f %c", fabs(value), hemi);
}
// Formats a UTC offset as "UTC+9", "UTC-5", "UTC+5:30". cty.dat carries half
// hour offsets for a handful of entities, so the fractional case is real.
static void hammy_dxcc_fmt_offset(double offset, char* out, size_t cap) {
int totalMinutes = (int)lround(offset * 60.0);
char sign = totalMinutes < 0 ? '-' : '+';
if (totalMinutes < 0) { totalMinutes = -totalMinutes; }
int hours = totalMinutes / 60;
int minutes = totalMinutes % 60;
if (minutes) {
snprintf(out, cap, "UTC%c%d:%02d", sign, hours, minutes);
} else {
snprintf(out, cap, "UTC%c%d", sign, hours);
}
}
// Wall-clock time at the entity. cty.dat gives STANDARD offsets with no notion
// of summer time, so this can be an hour out for part of the year. Said so in
// the output rather than quietly presenting it as exact.
static void hammy_dxcc_fmt_local_time(double offset, char* out, size_t cap) {
time_t now = time(NULL);
if (now == (time_t)-1) {
snprintf(out, cap, "unknown");
return;
}
time_t shifted = now + (time_t)lround(offset * 3600.0);
struct tm tmBuf;
// gmtime_r rather than gmtime: instant commands run on the gateway thread
// and deferred ones on a worker, and gmtime's static buffer is shared.
if (!gmtime_r(&shifted, &tmBuf)) {
snprintf(out, cap, "unknown");
return;
}
snprintf(out, cap, "%02d:%02d", tmBuf.tm_hour, tmBuf.tm_min);
}
// Instant command: runs on the gateway thread, sends a fresh response.
void hammy_cmd_dxcc(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* callsign = hammy_job_get_arg(job, "callsign");
if (!callsign || !*callsign) {
hammy_job_respond(job, client, "Error", "No callsign provided for lookup.", true);
return;
}
hammy_dxcc_t entity;
if (!hammy_refdb_dxcc(refdb, callsign, &entity)) {
char body[HAMMY_DXCC_BODY_MAX];
snprintf(body, sizeof(body),
"No DXCC entity matches `%s`.\n\n"
"Prefixes are matched longest-first, so this usually means a typo "
"or a prefix that has never been allocated.", callsign);
hammy_job_respond(job, client, "Entity Not Found!", body, true);
return;
}
char gridStr[HAMMY_GRID_MAX] = "unknown";
bool haveEntityGrid = hammy_grid_from_latlon(entity.latitude, entity.longitude, 6,
gridStr, sizeof(gridStr));
char latStr[24];
char lonStr[24];
hammy_dxcc_fmt_coord(entity.latitude, true, latStr, sizeof(latStr));
hammy_dxcc_fmt_coord(entity.longitude, false, lonStr, sizeof(lonStr));
char offsetStr[24];
char timeStr[16];
hammy_dxcc_fmt_offset(entity.utcOffset, offsetStr, sizeof(offsetStr));
hammy_dxcc_fmt_local_time(entity.utcOffset, timeStr, sizeof(timeStr));
char title[HAMMY_DXCC_TITLE_MAX];
char upperCallsign[HAMMY_CALLSIGN_MAX];
snprintf(upperCallsign, sizeof(upperCallsign), "%s", callsign);
hammy_to_uppercase(upperCallsign);
snprintf(title, sizeof(title), "%s - %s", upperCallsign, entity.name);
char body[HAMMY_DXCC_BODY_MAX];
int len = 0;
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Entity: `%s` (DXCC `%d`)\n"
"Continent: `%s` CQ zone: `%d` ITU zone: `%d`\n",
entity.name, entity.entityId, entity.continent,
entity.cqZone, entity.ituZone);
if (haveEntityGrid) {
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Location: `%s` (%s, %s)\n", gridStr, latStr, lonStr);
} else {
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Location: %s, %s\n", latStr, lonStr);
}
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Local time: `%s` (`%s`, standard time)\n", timeStr, offsetStr);
// Saying WHICH rule matched matters more than it looks. An exact hit means
// cty.dat carries a whole-callsign override, and a short prefix hit on a
// long callsign is a hint that the entity guess may be loose.
if (entity.exact) {
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Matched: exact callsign rule `%s`\n", entity.matchedPrefix);
} else {
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Matched: prefix `%s`\n", entity.matchedPrefix);
}
// Optional second argument: the asker's own locator, which turns this from
// trivia into something you can point an antenna with.
const char* fromGrid = hammy_job_get_arg(job, "grid");
if (fromGrid && *fromGrid) {
double fromLat = 0.0;
double fromLon = 0.0;
if (!hammy_grid_to_latlon(fromGrid, &fromLat, &fromLon)) {
len += snprintf(body + len, sizeof(body) - (size_t)len,
"\nCould not read `%s` as a Maidenhead locator. "
"Try something like `JN76` or `JN76gb`.\n", fromGrid);
} else {
double distKm = 0.0;
double bearing = 0.0;
hammy_great_circle(fromLat, fromLon, entity.latitude, entity.longitude,
&distKm, &bearing);
double longKm = hammy_long_path_km(distKm);
double longBearing = hammy_reciprocal_bearing(bearing);
char fromNorm[HAMMY_GRID_MAX];
if (!hammy_grid_from_latlon(fromLat, fromLon, 6, fromNorm, sizeof(fromNorm))) {
snprintf(fromNorm, sizeof(fromNorm), "%s", fromGrid);
}
len += snprintf(body + len, sizeof(body) - (size_t)len,
"\n**From %s**\n"
"Short path: `%.0f km` (`%.0f mi`) bearing `%.0f` (%s)\n"
"Long path: `%.0f km` bearing `%.0f` (%s)\n",
fromNorm,
distKm, distKm * 0.621371, bearing, hammy_compass_point(bearing),
longKm, longBearing, hammy_compass_point(longBearing));
}
}
// The entity coordinates are a nominal center for the whole entity, not the
// station's own position. For the US that is a point in Washington DC, so a
// bearing to a California station will be well off. Worth admitting.
len += snprintf(body + len, sizeof(body) - (size_t)len,
"\n-# Coordinates are the entity's nominal center, not the "
"station's actual location.");
(void)len;
hammy_job_respond(job, client, title, body, false);
}
+2 -2
View File
@@ -195,9 +195,9 @@ void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, hammy_refdb_
// 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");
sb_addf(&sb, "\n> This is exactly the band edge. A signal of any width centerd 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");
sb_addf(&sb, "\n> This is exactly a segment boundary, so a signal centerd here straddles both sides.\n");
}
if (sb.overflow) {
+10
View File
@@ -61,6 +61,15 @@ static struct discord_application_command_options phonetic_opts_struct = {
.array = phonetic_opts
};
static struct discord_application_command_option dxcc_opts[] = {
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"callsign",
.description = (char*)"Callsign or prefix to look up", .required = true },
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"grid",
.description = (char*)"Your Maidenhead locator, for distance and bearing", .required = false },
};
static struct discord_application_command_options dxcc_opts_struct = { .size = 2, .array = dxcc_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.
@@ -72,6 +81,7 @@ static const hammy_command_t hammy_builtin_commands[] = {
{ "freq", "Band, segment and who can transmit", &freq_opts_struct, &hammy_cmd_freq, true },
{ "q", "Convert Q-Code to the corresponding question and answer", &q_opts_struct, &hammy_cmd_q, true },
{ "phonetic", "Convert Text to Phonetics", &phonetic_opts_struct, &hammy_cmd_phonetic, true },
{ "dxcc", "Look up the DXCC entity for a callsign", &dxcc_opts_struct, &hammy_cmd_dxcc, true },
{ "abbr", "Convert Abbreviation to Meaning and Context", &abbr_opts_struct, &hammy_cmd_abbr, true }
// Tier 0 commands go here as they land. All pure computation, so instant:
+193
View File
@@ -0,0 +1,193 @@
#include <ctype.h>
#include <math.h>
#include <stdbool.h>
#include <stddef.h>
#include <string.h>
#include <hammy/geo.h>
// M_PI is POSIX, not C99, so it is absent under -std=c99 without _GNU_SOURCE.
// Defining it here keeps the build strict and portable.
#define HAMMY_PI 3.14159265358979323846
// IUGG mean Earth radius. Any of the common radii agree to well within the
// error a 6-character grid square already carries.
#define HAMMY_EARTH_RADIUS_KM 6371.0088
#define HAMMY_EARTH_CIRCUM_KM (2.0 * HAMMY_PI * HAMMY_EARTH_RADIUS_KM)
static double deg_to_rad(double deg) { return deg * HAMMY_PI / 180.0; }
static double rad_to_deg(double rad) { return rad * 180.0 / HAMMY_PI; }
bool hammy_grid_from_latlon(double lat, double lon, int precision, char* out, size_t cap) {
if (!out || cap < 3) { return false; }
if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) { return false; }
if (precision != 2 && precision != 4 && precision != 6 && precision != 8) { return false; }
if (cap < (size_t)precision + 1) { return false; }
// Shift into the all-positive space the locator system is defined over.
double lonA = lon + 180.0;
double latA = lat + 90.0;
// Clamp the poles and the antimeridian so floor() cannot walk off the end
// of the field letters at exactly +90 / +180.
if (lonA >= 360.0) { lonA = 359.999999; }
if (latA >= 180.0) { latA = 179.999999; }
int i = 0;
int f1 = (int)floor(lonA / 20.0);
int f2 = (int)floor(latA / 10.0);
out[i++] = (char)('A' + f1);
out[i++] = (char)('A' + f2);
double remLon = lonA - f1 * 20.0;
double remLat = latA - f2 * 10.0;
if (precision >= 4) {
int s1 = (int)floor(remLon / 2.0);
int s2 = (int)floor(remLat / 1.0);
out[i++] = (char)('0' + s1);
out[i++] = (char)('0' + s2);
remLon -= s1 * 2.0;
remLat -= s2 * 1.0;
}
if (precision >= 6) {
// A square is 2 deg of longitude by 1 deg of latitude, divided 24 ways.
int ss1 = (int)floor(remLon / (2.0 / 24.0));
int ss2 = (int)floor(remLat / (1.0 / 24.0));
if (ss1 > 23) { ss1 = 23; }
if (ss2 > 23) { ss2 = 23; }
out[i++] = (char)('a' + ss1);
out[i++] = (char)('a' + ss2);
remLon -= ss1 * (2.0 / 24.0);
remLat -= ss2 * (1.0 / 24.0);
}
if (precision >= 8) {
int e1 = (int)floor(remLon / (2.0 / 240.0));
int e2 = (int)floor(remLat / (1.0 / 240.0));
if (e1 > 9) { e1 = 9; }
if (e2 > 9) { e2 = 9; }
out[i++] = (char)('0' + e1);
out[i++] = (char)('0' + e2);
}
out[i] = '\0';
return true;
}
bool hammy_grid_to_latlon(const char* grid, double* outLat, double* outLon) {
if (!grid || !outLat || !outLon) { return false; }
char g[HAMMY_GRID_MAX];
size_t n = 0;
for (size_t i = 0; grid[i]; i++) {
if (isspace((unsigned char)grid[i])) { continue; }
// Reject rather than truncate. Silently clipping "IO91wm12345" to
// "IO91wm12" would hand back a plausible-looking position for input the
// user clearly got wrong.
if (n + 1 >= sizeof(g)) { return false; }
g[n++] = grid[i];
}
g[n] = '\0';
// Locators come in even-length pairs; 2, 4, 6 and 8 are the useful ones.
if (n != 2 && n != 4 && n != 6 && n != 8) { return false; }
int f1 = toupper((unsigned char)g[0]) - 'A';
int f2 = toupper((unsigned char)g[1]) - 'A';
if (f1 < 0 || f1 > 17 || f2 < 0 || f2 > 17) { return false; }
double lon = f1 * 20.0;
double lat = f2 * 10.0;
double lonSize = 20.0;
double latSize = 10.0;
if (n >= 4) {
if (!isdigit((unsigned char)g[2]) || !isdigit((unsigned char)g[3])) { return false; }
lon += (g[2] - '0') * 2.0;
lat += (g[3] - '0') * 1.0;
lonSize = 2.0;
latSize = 1.0;
}
if (n >= 6) {
int s1 = tolower((unsigned char)g[4]) - 'a';
int s2 = tolower((unsigned char)g[5]) - 'a';
if (s1 < 0 || s1 > 23 || s2 < 0 || s2 > 23) { return false; }
lon += s1 * (2.0 / 24.0);
lat += s2 * (1.0 / 24.0);
lonSize = 2.0 / 24.0;
latSize = 1.0 / 24.0;
}
if (n >= 8) {
if (!isdigit((unsigned char)g[6]) || !isdigit((unsigned char)g[7])) { return false; }
lon += (g[6] - '0') * (2.0 / 240.0);
lat += (g[7] - '0') * (1.0 / 240.0);
lonSize = 2.0 / 240.0;
latSize = 1.0 / 240.0;
}
// Report the CENTER of the square, not its south-west corner. Using the
// corner biases every distance by up to half a square, which at 4-character
// precision is about 60 km.
*outLon = lon + lonSize / 2.0 - 180.0;
*outLat = lat + latSize / 2.0 - 90.0;
return true;
}
void hammy_great_circle(double lat1, double lon1, double lat2, double lon2,
double* outDistKm, double* outBearingDeg) {
double p1 = deg_to_rad(lat1);
double p2 = deg_to_rad(lat2);
double dp = p2 - p1;
double dl = deg_to_rad(lon2 - lon1);
if (outDistKm) {
// Haversine. The spherical law of cosines is shorter but loses precision
// for short distances, which is exactly the "how far to the next town"
// case people try first.
double a = sin(dp / 2.0) * sin(dp / 2.0)
+ cos(p1) * cos(p2) * sin(dl / 2.0) * sin(dl / 2.0);
if (a > 1.0) { a = 1.0; }
*outDistKm = 2.0 * HAMMY_EARTH_RADIUS_KM * asin(sqrt(a));
}
if (outBearingDeg) {
double y = sin(dl) * cos(p2);
double x = cos(p1) * sin(p2) - sin(p1) * cos(p2) * cos(dl);
double b = rad_to_deg(atan2(y, x));
*outBearingDeg = fmod(b + 360.0, 360.0);
}
}
double hammy_long_path_km(double shortPathKm) {
return HAMMY_EARTH_CIRCUM_KM - shortPathKm;
}
double hammy_reciprocal_bearing(double bearingDeg) {
return fmod(bearingDeg + 180.0, 360.0);
}
const char* hammy_compass_point(double bearingDeg) {
static const char* POINTS[16] = {
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
};
double b = fmod(bearingDeg + 360.0, 360.0);
int idx = (int)floor((b + 11.25) / 22.5) % 16;
return POINTS[idx];
}
+1 -1
View File
@@ -821,7 +821,7 @@ bool hammy_refdb_freq(hammy_refdb_t* db, int64_t freqHz, const char* country,
sqlite3_reset(st);
// Sitting exactly on a segment boundary is worth flagging: a signal of any
// width centred there straddles both sides.
// width centerd there straddles both sides.
st = db->stFreqSegEdge;
sqlite3_reset(st);
sqlite3_clear_bindings(st);