Add /dxcc command for converting a callsign into a DXCC entity - optional grid argument calculates distance from
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user