diff --git a/README.md b/README.md index 774b99f..3603c0e 100644 --- a/README.md +++ b/README.md @@ -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! diff --git a/include/hammy/commands.h b/include/hammy/commands.h index 7d67f4b..26bb7ad 100644 --- a/include/hammy/commands.h +++ b/include/hammy/commands.h @@ -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 diff --git a/include/hammy/geo.h b/include/hammy/geo.h new file mode 100644 index 0000000..2870a59 --- /dev/null +++ b/include/hammy/geo.h @@ -0,0 +1,43 @@ +#ifndef HAMMY_GEO_H +#define HAMMY_GEO_H + +#include +#include + +// 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 diff --git a/include/hammy/refdb.h b/include/hammy/refdb.h index b939a7a..114d332 100644 --- a/include/hammy/refdb.h +++ b/include/hammy/refdb.h @@ -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; diff --git a/sqlite/03-bands.sql b/sqlite/03-bands.sql index e73bed2..1a93049 100644 --- a/sqlite/03-bands.sql +++ b/sqlite/03-bands.sql @@ -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 diff --git a/src/commands/dxcc.c b/src/commands/dxcc.c new file mode 100644 index 0000000..129e9ec --- /dev/null +++ b/src/commands/dxcc.c @@ -0,0 +1,191 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#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); +} diff --git a/src/commands/freq.c b/src/commands/freq.c index 36be745..6ad2b6d 100644 --- a/src/commands/freq.c +++ b/src/commands/freq.c @@ -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) { diff --git a/src/hammy/command.c b/src/hammy/command.c index 825593f..c0653b5 100644 --- a/src/hammy/command.c +++ b/src/hammy/command.c @@ -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: diff --git a/src/hammy/geo.c b/src/hammy/geo.c new file mode 100644 index 0000000..1fc1115 --- /dev/null +++ b/src/hammy/geo.c @@ -0,0 +1,193 @@ +#include +#include +#include +#include +#include + +#include + +// 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]; +} diff --git a/src/hammy/refdb.c b/src/hammy/refdb.c index cc76cde..a434d50 100644 --- a/src/hammy/refdb.c +++ b/src/hammy/refdb.c @@ -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);