Compare commits
28
Commits
9ea5d69cc1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d43a65f1f
|
||
|
|
af7ed86424
|
||
|
|
3dcb73aaec
|
||
|
|
623d121c05
|
||
|
|
c7d2052fac
|
||
|
|
fcf510afa4
|
||
|
|
3d6b08d9d5
|
||
|
|
cca190e2f4
|
||
|
|
7ca1f80c01
|
||
|
|
d7b9582cc0
|
||
|
|
ee045cd1dd
|
||
|
|
ae5389a7d5
|
||
|
|
cab60bbd57
|
||
|
|
923ddc5bd2
|
||
|
|
44d13ab071
|
||
|
|
c056b1b67a
|
||
|
|
7acb2aabd4
|
||
|
|
2aa0ae3d9a
|
||
|
|
7b5514bf67
|
||
|
|
7e4e738c19
|
||
|
|
35591b0701
|
||
|
|
9b7ce082c2
|
||
|
|
177528cf60
|
||
|
|
faa73838c0
|
||
|
|
57ff0d0378
|
||
|
|
ee3aa1e488
|
||
|
|
ecc792bcb6
|
||
|
|
e6eba80150
|
@@ -1,4 +1,8 @@
|
|||||||
.env
|
.env
|
||||||
build/
|
build/
|
||||||
|
build*/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
config.json
|
config.json
|
||||||
|
.vscode
|
||||||
|
.vscode/
|
||||||
|
sqlite/*.sqlite
|
||||||
|
|||||||
Vendored
-17
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Linux",
|
|
||||||
"includePath": [
|
|
||||||
"${workspaceFolder}/**",
|
|
||||||
"${workspaceFolder}/include",
|
|
||||||
"${workspaceFolder}/third_party"
|
|
||||||
],
|
|
||||||
"defines": [],
|
|
||||||
"compilerPath": "/usr/bin/clang",
|
|
||||||
"cStandard": "c17",
|
|
||||||
"intelliSenseMode": "linux-clang-x64"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": 4
|
|
||||||
}
|
|
||||||
+98
-9
@@ -198,7 +198,7 @@ else()
|
|||||||
-Wshadow
|
-Wshadow
|
||||||
-Wconversion
|
-Wconversion
|
||||||
-Wsign-conversion
|
-Wsign-conversion
|
||||||
-Wcast-qual
|
#-Wcast-qual
|
||||||
-Wcast-align
|
-Wcast-align
|
||||||
-Wstrict-prototypes
|
-Wstrict-prototypes
|
||||||
-Wmissing-prototypes
|
-Wmissing-prototypes
|
||||||
@@ -215,6 +215,7 @@ else()
|
|||||||
-Wundef
|
-Wundef
|
||||||
-Winit-self
|
-Winit-self
|
||||||
-Wmissing-include-dirs
|
-Wmissing-include-dirs
|
||||||
|
-Wno-discarded-qualifiers # Temp
|
||||||
)
|
)
|
||||||
|
|
||||||
if(CMAKE_C_COMPILER_ID MATCHES "GNU")
|
if(CMAKE_C_COMPILER_ID MATCHES "GNU")
|
||||||
@@ -245,7 +246,9 @@ else()
|
|||||||
hammy_append_supported_c_flags(HAMMY_ANALYZER_FLAGS
|
hammy_append_supported_c_flags(HAMMY_ANALYZER_FLAGS
|
||||||
-fanalyzer-verbosity=${ANALYZER_VERBOSITY}
|
-fanalyzer-verbosity=${ANALYZER_VERBOSITY}
|
||||||
-fanalyzer
|
-fanalyzer
|
||||||
)
|
--param=analyzer-checker=taint
|
||||||
|
--Wanalyzer-too-complex
|
||||||
|
)
|
||||||
endif()
|
endif()
|
||||||
elseif(CMAKE_C_COMPILER_ID MATCHES "Clang")
|
elseif(CMAKE_C_COMPILER_ID MATCHES "Clang")
|
||||||
# Clang-only diagnostics. Clang has no in-compiler equivalent of
|
# Clang-only diagnostics. Clang has no in-compiler equivalent of
|
||||||
@@ -337,7 +340,14 @@ if(HAMMY_ENABLE_LTO)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Concord (Discord API wrapper), fetched from the dev branch
|
# Concord (Discord API wrapper), pinned to the v3.0.1 release
|
||||||
|
#
|
||||||
|
# Pinned rather than tracking dev: dev reverted the notifier's portable fcntl
|
||||||
|
# setup back to ioctl(FIONBIO), cast to int. macOS FIONBIO is 0x8004667E, so the
|
||||||
|
# cast goes negative, sign-extends into ioctl's unsigned long parameter and the
|
||||||
|
# call fails -- ccord_global_init() then dies before the client is ever built.
|
||||||
|
# The release tags still carry the fcntl version. Check that the regression is
|
||||||
|
# gone before moving this back to a branch.
|
||||||
#
|
#
|
||||||
# Concord ships a hand-written Makefile, not a CMake build, so this is a
|
# Concord ships a hand-written Makefile, not a CMake build, so this is a
|
||||||
# two-stage arrangement: FetchContent clones it at configure time (recursively,
|
# two-stage arrangement: FetchContent clones it at configure time (recursively,
|
||||||
@@ -358,7 +368,7 @@ include(ExternalProject)
|
|||||||
FetchContent_Declare(
|
FetchContent_Declare(
|
||||||
concord
|
concord
|
||||||
GIT_REPOSITORY https://github.com/Cogmasters/concord.git
|
GIT_REPOSITORY https://github.com/Cogmasters/concord.git
|
||||||
GIT_TAG dev
|
GIT_TAG v3.0.1
|
||||||
GIT_SHALLOW TRUE
|
GIT_SHALLOW TRUE
|
||||||
GIT_PROGRESS TRUE
|
GIT_PROGRESS TRUE
|
||||||
)
|
)
|
||||||
@@ -395,19 +405,25 @@ else()
|
|||||||
# absolute paths and name the two failure modes that actually occur.
|
# absolute paths and name the two failure modes that actually occur.
|
||||||
#
|
#
|
||||||
# The globs run at install time rather than configure time because
|
# The globs run at install time rather than configure time because
|
||||||
# gencodecs/discord_codecs.h does not exist until the build has generated it.
|
# generated/discord_codecs.h does not exist until the build has emitted it.
|
||||||
|
#
|
||||||
|
# Keep the directory list below in step with the `install:` target in
|
||||||
|
# upstream's Makefile; v3.0.1 renamed gencodecs/ to generated/ and split
|
||||||
|
# reflect-c.h out into its own directory.
|
||||||
set(CONCORD_INSTALL_SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/concord_install.cmake")
|
set(CONCORD_INSTALL_SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/concord_install.cmake")
|
||||||
file(CONFIGURE
|
file(CONFIGURE
|
||||||
OUTPUT "${CONCORD_INSTALL_SCRIPT}"
|
OUTPUT "${CONCORD_INSTALL_SCRIPT}"
|
||||||
@ONLY
|
@ONLY
|
||||||
CONTENT [[
|
CONTENT [[
|
||||||
|
cmake_policy(SET CMP0057 NEW)
|
||||||
|
|
||||||
file(MAKE_DIRECTORY "@CONCORD_INCLUDE_DIR@/concord" "@CONCORD_PREFIX@/lib")
|
file(MAKE_DIRECTORY "@CONCORD_INCLUDE_DIR@/concord" "@CONCORD_PREFIX@/lib")
|
||||||
|
|
||||||
# Upstream flattens all three header directories into one include/concord. Check
|
# Upstream flattens all three header directories into one include/concord. Check
|
||||||
# them one at a time: a single empty directory is the interesting failure, and a
|
# them one at a time: a single empty directory is the interesting failure, and a
|
||||||
# combined glob would hide it behind whichever siblings still matched.
|
# combined glob would hide it behind whichever siblings still matched.
|
||||||
set(_hdrs "")
|
set(_hdrs "")
|
||||||
foreach(_dir include core gencodecs)
|
foreach(_dir include core generated)
|
||||||
file(GLOB _found "@concord_SOURCE_DIR@/${_dir}/*.h")
|
file(GLOB _found "@concord_SOURCE_DIR@/${_dir}/*.h")
|
||||||
if(NOT _found)
|
if(NOT _found)
|
||||||
message(FATAL_ERROR
|
message(FATAL_ERROR
|
||||||
@@ -416,8 +432,53 @@ foreach(_dir include core gencodecs)
|
|||||||
endif()
|
endif()
|
||||||
list(APPEND _hdrs ${_found})
|
list(APPEND _hdrs ${_found})
|
||||||
endforeach()
|
endforeach()
|
||||||
|
|
||||||
|
# Not part of any of the three directories, but generated/discord_codecs.h
|
||||||
|
# includes it, so discord.h does not parse without it.
|
||||||
|
if(NOT EXISTS "@concord_SOURCE_DIR@/reflect-c/reflect-c.h")
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"Concord: missing @concord_SOURCE_DIR@/reflect-c/reflect-c.h -- the "
|
||||||
|
"reflect-c submodule was not cloned; remove build/_deps and reconfigure")
|
||||||
|
endif()
|
||||||
|
list(APPEND _hdrs "@concord_SOURCE_DIR@/reflect-c/reflect-c.h")
|
||||||
|
|
||||||
file(COPY ${_hdrs} DESTINATION "@CONCORD_INCLUDE_DIR@/concord")
|
file(COPY ${_hdrs} DESTINATION "@CONCORD_INCLUDE_DIR@/concord")
|
||||||
|
|
||||||
|
# third_party/concord is a checked-in mirror of the headers just installed. It
|
||||||
|
# exists so an editor has something to resolve <concord/*.h> against in a fresh
|
||||||
|
# clone, before anything has been built -- build/ is gitignored, so the copy
|
||||||
|
# above is not there yet.
|
||||||
|
#
|
||||||
|
# It is refreshed from the same file list on every build rather than left to be
|
||||||
|
# updated by hand, because it also sits FIRST on hammy's include path
|
||||||
|
# (-isystem third_party precedes the installed headers). A stale mirror would
|
||||||
|
# not merely confuse the editor, it would be what the compiler actually reads --
|
||||||
|
# hammy would build against one version of Concord and link another. Keeping the
|
||||||
|
# two byte-identical makes the ordering irrelevant. Moving GIT_TAG therefore
|
||||||
|
# shows up as a diff under third_party/concord; commit it along with the bump.
|
||||||
|
#
|
||||||
|
# Prune first: a version bump can retire a header, and one left behind in the
|
||||||
|
# mirror would still be found by the compiler. Only files that vanished upstream
|
||||||
|
# are removed -- file(COPY) preserves timestamps and skips files already
|
||||||
|
# matching, so mirroring costs one stat per header on a build that changed
|
||||||
|
# nothing. The exception is discord_codecs.h, which Concord's own build
|
||||||
|
# regenerates every time (BUILD_ALWAYS re-runs make); its mtime moves, so it is
|
||||||
|
# recopied. That does not add a rebuild -- the regenerated original already
|
||||||
|
# forces one -- and its contents are deterministic, so git stays clean.
|
||||||
|
set(_want "")
|
||||||
|
foreach(_hdr IN LISTS _hdrs)
|
||||||
|
get_filename_component(_name "${_hdr}" NAME)
|
||||||
|
list(APPEND _want "${_name}")
|
||||||
|
endforeach()
|
||||||
|
file(GLOB _mirrored "@CMAKE_CURRENT_SOURCE_DIR@/third_party/concord/*.h")
|
||||||
|
foreach(_old IN LISTS _mirrored)
|
||||||
|
get_filename_component(_name "${_old}" NAME)
|
||||||
|
if(NOT "${_name}" IN_LIST _want)
|
||||||
|
file(REMOVE "${_old}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
file(COPY ${_hdrs} DESTINATION "@CMAKE_CURRENT_SOURCE_DIR@/third_party/concord")
|
||||||
|
|
||||||
file(GLOB _libs "@concord_SOURCE_DIR@/lib/libdiscord.*")
|
file(GLOB _libs "@concord_SOURCE_DIR@/lib/libdiscord.*")
|
||||||
if(NOT _libs)
|
if(NOT _libs)
|
||||||
message(FATAL_ERROR "Concord: build produced no library in @concord_SOURCE_DIR@/lib")
|
message(FATAL_ERROR "Concord: build produced no library in @concord_SOURCE_DIR@/lib")
|
||||||
@@ -431,7 +492,20 @@ file(COPY ${_libs} DESTINATION "@CONCORD_PREFIX@/lib")
|
|||||||
# the environment rather than the make command line because Concord's
|
# the environment rather than the make command line because Concord's
|
||||||
# Makefile appends its own -I flags to it; a command-line assignment would
|
# Makefile appends its own -I flags to it; a command-line assignment would
|
||||||
# override those and break the build.
|
# override those and break the build.
|
||||||
set(CONCORD_CFLAGS "-O2 -g $<JOIN:${HAMMY_INSTRUMENT_COMPILE}, >")
|
# ... with one carve-out. chash.h's string hash is djb2-style:
|
||||||
|
# (hash) = (((hash) << 1) + (hash)) + key[i];
|
||||||
|
# on a signed accumulator, so it deliberately relies on wraparound. That is
|
||||||
|
# two counts of UB per character -- the shift of a negative value and the
|
||||||
|
# overflowing add -- plus a third in the __chash_abs() that follows. With
|
||||||
|
# -fno-sanitize-recover it aborts the first time a ratelimit key hashes past
|
||||||
|
# LONG_MAX, which a guild command endpoint manages immediately.
|
||||||
|
#
|
||||||
|
# The wraparound is benign and the code is not ours to fix, so drop these
|
||||||
|
# two checks for Concord's build only. Everything else UBSan looks at stays
|
||||||
|
# on, and our own translation units keep the full set -- these flags are not
|
||||||
|
# in HAMMY_SANITIZER_FLAGS, only here.
|
||||||
|
set(CONCORD_UB_CARVEOUTS "-fno-sanitize=signed-integer-overflow -fno-sanitize=shift")
|
||||||
|
set(CONCORD_CFLAGS "-O2 -g $<JOIN:${HAMMY_INSTRUMENT_COMPILE}, > $<${HAMMY_IS_SANITIZED}:${CONCORD_UB_CARVEOUTS}>")
|
||||||
|
|
||||||
# gencodecs/Makefile hardcodes CC/HOSTCC/CPP to "cc" for its host-side code
|
# gencodecs/Makefile hardcodes CC/HOSTCC/CPP to "cc" for its host-side code
|
||||||
# generator, with plain '=' assignments that the environment cannot override.
|
# generator, with plain '=' assignments that the environment cannot override.
|
||||||
@@ -482,6 +556,15 @@ file(COPY ${_libs} DESTINATION "@CONCORD_PREFIX@/lib")
|
|||||||
set(HAMMY_CONCORD_EXTERNAL concord_external)
|
set(HAMMY_CONCORD_EXTERNAL concord_external)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# SQLite3 (hammy's own dependency, unlike the Concord ones above)
|
||||||
|
#
|
||||||
|
# CMake's bundled FindSQLite3 module (3.14+) exports the SQLite::SQLite3
|
||||||
|
# imported target and covers both a system package and a manually-specified
|
||||||
|
# SQLITE3_INCLUDE_DIR/SQLITE3_LIBRARY, so no vendoring is needed here.
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
find_package(SQLite3 REQUIRED)
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Output directories
|
# Output directories
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
@@ -514,7 +597,12 @@ target_include_directories(hammy SYSTEM PRIVATE
|
|||||||
# target's interface includes as -isystem, so upstream's headers never trip our
|
# target's interface includes as -isystem, so upstream's headers never trip our
|
||||||
# warning set and no second copy under third_party/ is needed.
|
# warning set and no second copy under third_party/ is needed.
|
||||||
|
|
||||||
target_link_libraries(hammy PRIVATE concord::concord)
|
target_link_libraries(hammy PRIVATE concord::concord SQLite::SQLite3)
|
||||||
|
|
||||||
|
if (NOT MSVC)
|
||||||
|
target_link_libraries(hammy PRIVATE m)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(HAMMY_CONCORD_EXTERNAL)
|
if(HAMMY_CONCORD_EXTERNAL)
|
||||||
add_dependencies(hammy ${HAMMY_CONCORD_EXTERNAL})
|
add_dependencies(hammy ${HAMMY_CONCORD_EXTERNAL})
|
||||||
endif()
|
endif()
|
||||||
@@ -559,7 +647,8 @@ message(STATUS "hammy: sanitizers [Debug/Strict] ${HAMMY_SANITIZER_FLAGS}")
|
|||||||
message(STATUS "hammy: static analyzer [Analyzer] ${HAMMY_ANALYZER_FLAGS}")
|
message(STATUS "hammy: static analyzer [Analyzer] ${HAMMY_ANALYZER_FLAGS}")
|
||||||
message(STATUS "hammy: hardening ${HAMMY_ENABLE_HARDENING}")
|
message(STATUS "hammy: hardening ${HAMMY_ENABLE_HARDENING}")
|
||||||
message(STATUS "hammy: LTO ${HAMMY_ENABLE_LTO}")
|
message(STATUS "hammy: LTO ${HAMMY_ENABLE_LTO}")
|
||||||
message(STATUS "hammy: concord ${concord_SOURCE_DIR} (branch dev)")
|
message(STATUS "hammy: concord ${concord_SOURCE_DIR} (v3.0.1)")
|
||||||
|
message(STATUS "hammy: sqlite3 ${SQLite3_LIBRARIES} (v${SQLite3_VERSION})")
|
||||||
if(CMAKE_BUILD_TYPE STREQUAL "Analyzer" AND NOT HAMMY_ANALYZER_FLAGS)
|
if(CMAKE_BUILD_TYPE STREQUAL "Analyzer" AND NOT HAMMY_ANALYZER_FLAGS)
|
||||||
message(STATUS "hammy: NOTE - Analyzer config has no static analyzer on "
|
message(STATUS "hammy: NOTE - Analyzer config has no static analyzer on "
|
||||||
"${CMAKE_C_COMPILER_ID}; use scan-build over this build tree")
|
"${CMAKE_C_COMPILER_ID}; use scan-build over this build tree")
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
# Hammy
|
# Hammy
|
||||||
|
|
||||||
A Discord Bot for Ham/Amateur Radio
|
A Discord Bot for Ham/Amateur Radio
|
||||||
|
|
||||||
Fun fact: The logo of Hammy is a function! It is: f(x) = e^(-x^2)sin(9x)
|
Fun fact: The logo of Hammy is a function! It is: f(x) = e^(-x^2)sin(9x)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
Current features:
|
||||||
|
- /freq <frequency (MHz)> [country (default: US)] - Show the band, segment and who can transmit (for that country).
|
||||||
|
- /abbr <abbreviation> - Convert Abbreviation to Meaning and Context of Meaning.
|
||||||
|
- /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!
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
Hammy is written in C, using the [Concord Library](https://github.com/Cogmasters/concord). It's multithreaded, with a thread pool system for queuing jobs.
|
||||||
|
|
||||||
|
User interactions can be of two types:
|
||||||
|
- Instant Interactions: Handled directly by the master thread, don't require any communication with the backend, performed on-bot.
|
||||||
|
- Non-Instant Interactions: Queued into the job pool, where a worker thread picks them up (handoff) for processing in the background and responds later.
|
||||||
|
|
||||||
## AI Disclosure
|
## AI Disclosure
|
||||||
AI tools were used responsibly in the creation of this project, mainly for cmakelists off a pre-made template. It has also been used for minor things like functions, reviews, commits, etc.
|
AI tools were used responsibly in the creation of this project, mainly for cmakelists off a pre-made template. It has also been used for minor things like functions, reviews, commits, etc.
|
||||||
Contributors are responsible for their commits, regardless of the usage of AI or not.
|
Contributors are responsible for their commits, regardless of the usage of AI or not.
|
||||||
|
|||||||
+28
-5
@@ -7,11 +7,15 @@
|
|||||||
|
|
||||||
#include <hammy/types.h>
|
#include <hammy/types.h>
|
||||||
#include <hammy/command.h>
|
#include <hammy/command.h>
|
||||||
|
#include <hammy/refdb.h>
|
||||||
|
|
||||||
struct hammy_bot_t {
|
struct hammy_bot_t {
|
||||||
struct discord* client; // Owning reference to the client - handoff from main.c
|
struct discord* client; // Owning reference to the client - handoff from main.c
|
||||||
bool commands_registered; // A flag if commands have been registered. Avoid re-registering every reconnect.
|
hammy_refdb_t* refdb; // Owning reference to sqlite - the master and each worker has their own
|
||||||
vector_t* commands; // vector_t of commands. Owning.
|
bool commandsRegistered; // A flag if commands have been registered. Avoid re-registering every reconnect.
|
||||||
|
vector_t* commands; // vector_t of commands. Owning the vector, NOT the elements.
|
||||||
|
hammy_pool_t* pool; // Owning reference to the thread pool.
|
||||||
|
u64snowflake appId; // Learned from the ready event.
|
||||||
};
|
};
|
||||||
|
|
||||||
// Alloc on heap and create
|
// Alloc on heap and create
|
||||||
@@ -20,13 +24,32 @@ hammy_bot_t* hammy_bot_create();
|
|||||||
// Sets the on_ready() call function pointer
|
// Sets the on_ready() call function pointer
|
||||||
bool hammy_bot_set_on_ready(hammy_bot_t* bot, void (*func)(struct discord* client, const struct discord_ready* event));
|
bool hammy_bot_set_on_ready(hammy_bot_t* bot, void (*func)(struct discord* client, const struct discord_ready* event));
|
||||||
|
|
||||||
|
// Copies every entry from hammy_command_builtins() into the vector.
|
||||||
|
bool hammy_bot_load_builtins(hammy_bot_t* bot);
|
||||||
|
|
||||||
|
// Starts the worker pool. Pass 0 for the defaults. Call this from the ready
|
||||||
|
// callback, NOT before hammy_bot_run(): the workers are built on
|
||||||
|
// discord_clone(), which only succeeds inside a gateway dispatch.
|
||||||
|
// Returns true if the pool is already running.
|
||||||
|
bool hammy_bot_start_pool(hammy_bot_t* bot, size_t nWorkers, size_t queueCap);
|
||||||
|
|
||||||
// Runs the bot. Return true on succeed, false on failure.
|
// Runs the bot. Return true on succeed, false on failure.
|
||||||
bool hammy_bot_run(hammy_bot_t* bot);
|
bool hammy_bot_run(hammy_bot_t* bot);
|
||||||
|
|
||||||
// Adds a command to the vector as a COPY, get rid of the old struct.
|
// Adds a command to the vector as a COPY. Commands own nothing, so the source
|
||||||
bool hammy_bot_add_command(hammy_bot_t* bot, hammy_command_t* command);
|
// struct needs no cleanup and may be a compound literal.
|
||||||
|
bool hammy_bot_add_command(hammy_bot_t* bot, const hammy_command_t* command);
|
||||||
|
|
||||||
// Registers all commands. Return true if succeeded or already registered; false if failure.
|
// Looks a command up by name. Returns a pointer into the vector's storage, or
|
||||||
|
// NULL. Valid until the vector is next modified, which after startup is never.
|
||||||
|
const hammy_command_t* hammy_bot_find_command(const hammy_bot_t* bot, const char* name);
|
||||||
|
|
||||||
|
// Wipes all commands from the Discord registry. Return true on success, false on failure.
|
||||||
|
bool hammy_bot_deregister_all_commands(hammy_bot_t* bot);
|
||||||
|
|
||||||
|
// Registers all commands with Discord. Return true if succeeded or already
|
||||||
|
// registered; false if failure. Requires bot->application_id to be set, so call
|
||||||
|
// this from on_ready, not before.
|
||||||
bool hammy_bot_register_commands(hammy_bot_t* bot);
|
bool hammy_bot_register_commands(hammy_bot_t* bot);
|
||||||
|
|
||||||
// Destroy bot, NULLs the passing reference
|
// Destroy bot, NULLs the passing reference
|
||||||
|
|||||||
+56
-8
@@ -1,18 +1,66 @@
|
|||||||
#ifndef HAMMY_COMMAND_H
|
#ifndef HAMMY_COMMAND_H
|
||||||
#define HAMMY_COMMAND_H
|
#define HAMMY_COMMAND_H
|
||||||
|
|
||||||
#include <stdlib.h>
|
#include <concord/discord.h>
|
||||||
#include <hammy/types.h>
|
#include <concord/log.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#include <hammy/types.h>
|
||||||
|
#include <hammy/refdb.h>
|
||||||
|
|
||||||
|
// A command handler. Runs either on the gateway thread (instant commands) or on
|
||||||
|
// a worker thread (deferred ones), so it must not assume either. client is
|
||||||
|
// whichever client is correct for the calling thread; use it and nothing else.
|
||||||
|
//
|
||||||
|
// Instant handlers send a fresh interaction response.
|
||||||
|
// Deferred handlers EDIT the already-deferred response, via hammy_job_reply()
|
||||||
|
// or discord_edit_original_interaction_response().
|
||||||
|
//
|
||||||
|
// The handler does NOT own the job and must not destroy it.
|
||||||
|
typedef void (*hammy_command_fn)(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
|
||||||
|
|
||||||
|
// Plain old data. Every field points at static storage, so this struct owns
|
||||||
|
// nothing, copies freely, and needs no destructor - the vector can be created
|
||||||
|
// with a NULL destructor hook.
|
||||||
struct hammy_command_t {
|
struct hammy_command_t {
|
||||||
// TODO: add shit here
|
const char* name; // Discord command name. Not owning; expected to be a literal.
|
||||||
char empty;
|
const char* description; // Not owning.
|
||||||
|
|
||||||
|
// Registration options, or NULL for none. Not owning; point at a static
|
||||||
|
// struct discord_application_command_options, e.g.
|
||||||
|
//
|
||||||
|
// static struct discord_application_command_option grid_opt_array[] = {
|
||||||
|
// { .type = DISCORD_APPLICATION_OPTION_STRING, .name = "locator",
|
||||||
|
// .description = "Maidenhead grid", .required = true },
|
||||||
|
// };
|
||||||
|
// static struct discord_application_command_options grid_opts = {
|
||||||
|
// .size = 1, .array = grid_opt_array
|
||||||
|
// };
|
||||||
|
struct discord_application_command_options* options;
|
||||||
|
|
||||||
|
hammy_command_fn handler;
|
||||||
|
|
||||||
|
// true -> pure computation; answered inline on the gateway thread, no
|
||||||
|
// defer, no queue. Must complete in microseconds.
|
||||||
|
// false -> may block (HTTP, DB, file IO); deferred and queued to a worker.
|
||||||
|
//
|
||||||
|
// Marking a blocking command instant stalls the gateway and risks missed
|
||||||
|
// heartbeats. When unsure, leave it false.
|
||||||
|
bool instant;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Creates the command on the heap, returns NULL if failed.
|
// The built-in command table. Returns a pointer to static storage; outCount
|
||||||
hammy_command_t* hammy_command_create();
|
// receives the number of entries. Never NULL.
|
||||||
|
const hammy_command_t* hammy_command_builtins(size_t* outCount);
|
||||||
|
|
||||||
// Destroys a command.
|
// Linear lookup by name over a plain array. The table is small enough that
|
||||||
void hammy_command_destroy(void* cmd);
|
// anything cleverer is not worth the code.
|
||||||
|
// Returns NULL if not found.
|
||||||
|
const hammy_command_t* hammy_command_find(const hammy_command_t* commands, size_t count,
|
||||||
|
const char* name);
|
||||||
|
|
||||||
|
// True if the command is safe to run inline on the gateway thread.
|
||||||
|
// Tolerates NULL so callers can skip a null check.
|
||||||
|
bool hammy_command_is_instant(const hammy_command_t* command);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// commands.h
|
||||||
|
#ifndef HAMMY_COMMANDS_H
|
||||||
|
#define HAMMY_COMMANDS_H
|
||||||
|
|
||||||
|
#include <concord/discord.h>
|
||||||
|
#include <hammy/types.h>
|
||||||
|
|
||||||
|
void hammy_cmd_ping(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Ping
|
||||||
|
void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Text -> Morse
|
||||||
|
void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Frequency lookup
|
||||||
|
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
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#ifndef HAMMY_EMBEDS_H
|
||||||
|
#define HAMMY_EMBEDS_H
|
||||||
|
|
||||||
|
#include <concord/discord.h>
|
||||||
|
|
||||||
|
// Constructs a generic error embed and writes it into out. Returns true on success and false on failure.
|
||||||
|
// You must allocate space for exactly one embed in the out array. This function cannot check this so please make sure yourself.
|
||||||
|
static inline bool hammy_embeds_genericerror(struct discord *client, struct discord_embed *out) {
|
||||||
|
if (!out || !client) { return false; }
|
||||||
|
|
||||||
|
static struct discord_embed_footer footer = { .text = (char*)"Hammy Bot" };
|
||||||
|
|
||||||
|
out[0] = (struct discord_embed){
|
||||||
|
.title = (char*)"An Error Occurred",
|
||||||
|
.description = (char*)"An error occurred while processing your request.",
|
||||||
|
.color = 0xFF0000,
|
||||||
|
.timestamp = discord_timestamp(client),
|
||||||
|
.footer = &footer,
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constructs a custom error embed and writes it into out. Returns true on success and false on failure.
|
||||||
|
// You must allocate space for exactly one embed in the out array. This function cannot check this so please make sure yourself.
|
||||||
|
static inline bool hammy_embeds_customerror(struct discord *client,
|
||||||
|
struct discord_embed *out,
|
||||||
|
struct discord_embed_fields *fieldsOut,
|
||||||
|
const char *title,
|
||||||
|
const char *description,
|
||||||
|
struct discord_embed_field *fields,
|
||||||
|
int fieldCount)
|
||||||
|
{
|
||||||
|
if (!out || !client || !title || !description) { return false; }
|
||||||
|
if (fieldCount < 0 || (fieldCount > 0 && !fields)) { return false; }
|
||||||
|
if (fieldCount > 0 && !fieldsOut) { return false; }
|
||||||
|
|
||||||
|
static struct discord_embed_footer footer = { .text = (char*)"Hammy Bot" };
|
||||||
|
|
||||||
|
if (fieldCount > 0) {
|
||||||
|
*fieldsOut = (struct discord_embed_fields){
|
||||||
|
.size = fieldCount,
|
||||||
|
.array = fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
out[0] = (struct discord_embed){
|
||||||
|
.title = (char*)title,
|
||||||
|
.description = (char*)description,
|
||||||
|
.color = 0xFF0000,
|
||||||
|
.timestamp = discord_timestamp(client),
|
||||||
|
.footer = &footer,
|
||||||
|
.fields = (fieldCount > 0) ? fieldsOut : NULL,
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constructs a custom generic embed and writes it into out. Returns true on success and false on failure.
|
||||||
|
// You must allocate space for exactly one embed in the out array. This function cannot check this so please make sure yourself.
|
||||||
|
static inline bool hammy_embeds_customembed(struct discord *client,
|
||||||
|
struct discord_embed *out,
|
||||||
|
struct discord_embed_fields *fieldsOut,
|
||||||
|
const char *title,
|
||||||
|
const char *description,
|
||||||
|
struct discord_embed_field *fields,
|
||||||
|
int fieldCount,
|
||||||
|
int color)
|
||||||
|
{
|
||||||
|
if (!out || !client || !title || !description) { return false; }
|
||||||
|
if (fieldCount < 0 || (fieldCount > 0 && !fields)) { return false; }
|
||||||
|
if (fieldCount > 0 && !fieldsOut) { return false; }
|
||||||
|
|
||||||
|
static struct discord_embed_footer footer = { .text = (char*)"Hammy Bot" };
|
||||||
|
|
||||||
|
if (fieldCount > 0) {
|
||||||
|
*fieldsOut = (struct discord_embed_fields){
|
||||||
|
.size = fieldCount,
|
||||||
|
.array = fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
out[0] = (struct discord_embed){
|
||||||
|
.title = (char*)title,
|
||||||
|
.description = (char*)description,
|
||||||
|
.color = color,
|
||||||
|
.timestamp = discord_timestamp(client),
|
||||||
|
.footer = &footer,
|
||||||
|
.fields = (fieldCount > 0) ? fieldsOut : NULL,
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -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
|
||||||
+16
-4
@@ -7,6 +7,7 @@
|
|||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
#include <hammy/types.h>
|
#include <hammy/types.h>
|
||||||
|
#include <hammy/refdb.h>
|
||||||
|
|
||||||
// A single slash-command option. Flattened out of the interaction event.
|
// A single slash-command option. Flattened out of the interaction event.
|
||||||
// Both strings are owned by the job.
|
// Both strings are owned by the job.
|
||||||
@@ -30,12 +31,14 @@ struct hammy_job_t {
|
|||||||
hammy_arg_t* args; // Array of size nArgs. Owning, array and contents.
|
hammy_arg_t* args; // Array of size nArgs. Owning, array and contents.
|
||||||
size_t nArgs;
|
size_t nArgs;
|
||||||
int64_t queuedAt; // Staleness checks, in ms. From discord_timestamp().
|
int64_t queuedAt; // Staleness checks, in ms. From discord_timestamp().
|
||||||
|
|
||||||
|
hammy_bot_t* bot; // NOT owning, used to access the command table
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deep-copies everything the job needs out of the interaction event, so the
|
// Deep-copies everything the job needs out of the interaction event, so the
|
||||||
// event may be freed by Concord the moment the handler returns.
|
// event may be freed by Concord the moment the handler returns.
|
||||||
// Returns NULL on allocation failure.
|
// Returns NULL on allocation failure.
|
||||||
hammy_job_t* hammy_job_create(struct discord* client, const struct discord_interaction* event);
|
hammy_job_t* hammy_job_create(hammy_bot_t* bot, const struct discord_interaction* event);
|
||||||
|
|
||||||
// Frees the job and everything it owns. NULLs the passing reference.
|
// Frees the job and everything it owns. NULLs the passing reference.
|
||||||
// Safe to call with NULL or with a pointer to NULL.
|
// Safe to call with NULL or with a pointer to NULL.
|
||||||
@@ -50,10 +53,19 @@ int64_t hammy_job_age_ms(const hammy_job_t* job, struct discord* client);
|
|||||||
// Runs the job to completion and sends the reply. Called from a worker thread,
|
// Runs the job to completion and sends the reply. Called from a worker thread,
|
||||||
// so client MUST be that worker's own clone, never the gateway client.
|
// so client MUST be that worker's own clone, never the gateway client.
|
||||||
// Does not destroy the job.
|
// Does not destroy the job.
|
||||||
void hammy_job_run(hammy_job_t* job, struct discord* client);
|
void hammy_job_run(hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
|
||||||
|
|
||||||
// Edits the (already deferred) interaction response with a plain text body.
|
// Edits the (already deferred) interaction response with a titled embed,
|
||||||
|
// falling back to a plain text body only if the embed cannot be built.
|
||||||
// Used by hammy_job_run() and by the pool's error paths.
|
// Used by hammy_job_run() and by the pool's error paths.
|
||||||
void hammy_job_reply(const hammy_job_t* job, struct discord* client, const char* content);
|
//
|
||||||
|
// Only valid on the deferred path: it edits a response, so something must have
|
||||||
|
// answered the interaction already. Instant handlers want hammy_job_respond().
|
||||||
|
void hammy_job_reply(const hammy_job_t* job, struct discord* client, const char* title, const char* content, bool isError);
|
||||||
|
|
||||||
|
// Sends a fresh interaction response with a titled embed, falling back to a
|
||||||
|
// plain text body only if the embed cannot be built.
|
||||||
|
// Only valid on the instant path, where nothing has been sent yet.
|
||||||
|
void hammy_job_respond(const hammy_job_t* job, struct discord* client, const char* title, const char* content, bool isError);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ struct hammy_pool_t {
|
|||||||
// Creates the pool and starts n_workers threads, each with its own
|
// Creates the pool and starts n_workers threads, each with its own
|
||||||
// discord_clone() of client. Pass 0 for either size to take the defaults.
|
// discord_clone() of client. Pass 0 for either size to take the defaults.
|
||||||
// Returns NULL on failure; no threads are left running in that case.
|
// Returns NULL on failure; no threads are left running in that case.
|
||||||
|
// MUST be called from inside a gateway dispatch callback (on_ready is the
|
||||||
|
// earliest one): discord_clone() copies the gateway's current payload and
|
||||||
|
// fails with CCORD_ERRNO when there isn't one.
|
||||||
hammy_pool_t* hammy_pool_create(struct discord* client, size_t nWorkers, size_t queueCap);
|
hammy_pool_t* hammy_pool_create(struct discord* client, size_t nWorkers, size_t queueCap);
|
||||||
|
|
||||||
// Enqueues a job. See hammy_push_result_t for who owns the job afterwards.
|
// Enqueues a job. See hammy_push_result_t for who owns the job afterwards.
|
||||||
@@ -56,7 +59,8 @@ hammy_push_result_t hammy_pool_push(hammy_pool_t* pool, hammy_job_t* job);
|
|||||||
void hammy_pool_shutdown(hammy_pool_t* pool);
|
void hammy_pool_shutdown(hammy_pool_t* pool);
|
||||||
|
|
||||||
// As above but discards anything still queued (each dropped job gets an
|
// As above but discards anything still queued (each dropped job gets an
|
||||||
// apology reply if it can be sent quickly).
|
// apology reply). Call from the gateway thread only - the apologies are
|
||||||
|
// serialised through the original client, which that thread owns.
|
||||||
void hammy_pool_shutdown_now(hammy_pool_t* pool);
|
void hammy_pool_shutdown_now(hammy_pool_t* pool);
|
||||||
|
|
||||||
// Frees the pool. Runs hammy_pool_shutdown() first if it has not happened yet.
|
// Frees the pool. Runs hammy_pool_shutdown() first if it has not happened yet.
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#ifndef HAMMY_REFDB_H
|
||||||
|
#define HAMMY_REFDB_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <sqlite3.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include <hammy/types.h>
|
||||||
|
|
||||||
|
// Read-only handle to the reference bundle. Owned by exactly one thread.
|
||||||
|
// Theading rules: Do NOT share a hammy_refdb_t between threads. Instead, a worker should
|
||||||
|
// open it's own in hammy_worker_start(), and the gateway should open one for instant
|
||||||
|
// commands. The bundle is read-only, so no writers for sync. Seperate sqlite3 connections
|
||||||
|
// never contend.
|
||||||
|
|
||||||
|
#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
|
||||||
|
|
||||||
|
// Longest Phonetic code. Fuck it, 32 characters
|
||||||
|
#define HAMMY_PHONETIC_MAX 32
|
||||||
|
|
||||||
|
// The current longest string in the qcodes table is 42; 128 is pretty generous. TODO: Change this if the qcodes table ever changes
|
||||||
|
#define HAMMY_QCODE_MAX 128
|
||||||
|
|
||||||
|
// Abbreviations
|
||||||
|
#define HAMMY_ABBR_LONGEST_STR 96
|
||||||
|
#define HAMMY_ABBR_LONGEST_CTX 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* stQCode;
|
||||||
|
sqlite3_stmt* stPhonetic;
|
||||||
|
sqlite3_stmt* stAbbr;
|
||||||
|
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 phoneticCode[HAMMY_PHONETIC_MAX];
|
||||||
|
char phoneticCodePronunciation[HAMMY_PHONETIC_MAX];
|
||||||
|
|
||||||
|
char abbrStr[HAMMY_ABBR_LONGEST_STR];
|
||||||
|
char abbrCtx[HAMMY_ABBR_LONGEST_CTX];
|
||||||
|
|
||||||
|
char qcodeQuestion[HAMMY_QCODE_MAX]; // Scratch for the last hammy_refdb_get_qcode() hit
|
||||||
|
char qcodeAnswer[HAMMY_QCODE_MAX]; // Scratch for the last hammy_refdb_get_qcode() hit
|
||||||
|
|
||||||
|
char version[64];
|
||||||
|
};
|
||||||
|
|
||||||
|
struct hammy_dxcc_t {
|
||||||
|
int entityId; // ADIF DXCC entity code
|
||||||
|
char name[HAMMY_ENTITY_NAME_MAX];
|
||||||
|
char continent[4];
|
||||||
|
int cqZone; // Override applied if present
|
||||||
|
int ituZone;
|
||||||
|
double latitude; // North-positive
|
||||||
|
double longitude; // East-positive
|
||||||
|
double utcOffset; // UTC + utcOffset = local
|
||||||
|
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 centerd 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.
|
||||||
|
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
|
||||||
|
// GLOB form puts the indexed column on the wrong side of the comparison, so
|
||||||
|
// SQLite scans all 7000 prefix rows; generating the candidates and seeking by
|
||||||
|
// equality is roughly 40x faster on the current bundle.
|
||||||
|
//
|
||||||
|
// Returns false if nothing matched.
|
||||||
|
bool hammy_refdb_dxcc(hammy_refdb_t* db, const char* callsign, hammy_dxcc_t* out);
|
||||||
|
|
||||||
|
// Looks up a character in the Morse table. Case-insensitive; non-ASCII bytes
|
||||||
|
// never match. Returns false if not found, leaving *out untouched.
|
||||||
|
//
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Looks up a character in the Phonetic table. Case-insensitive; non-ASCII bytes
|
||||||
|
// never match. Returns false if not found, leaving *out(s) untouched.
|
||||||
|
//
|
||||||
|
// 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_phonetic(hammy_refdb_t* db, char c, const char** out, const char** outPronunciation);
|
||||||
|
|
||||||
|
// Looks up a QSO code in the qcodes table. Case-insensitive; non-ASCII bytes
|
||||||
|
// never match. Returns false if not found, leaving *out(s) untouched.
|
||||||
|
//
|
||||||
|
// On a hit *out points at the storage owned by 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_qcode(hammy_refdb_t* db, const char* code, const char** outQuestion, const char** outAnswer);
|
||||||
|
|
||||||
|
// Looks up an abbreviation in the abbreviations table. Case-insensitive; non-ASCII bytes
|
||||||
|
// never match. Returns false if not found, leaving *out(s) untouched.
|
||||||
|
//
|
||||||
|
// On a hit *out points at the storage owned by 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_abbr(hammy_refdb_t* db, const char* code, const char** outStr, const char** outContext);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -6,5 +6,10 @@ typedef struct hammy_job_t hammy_job_t;
|
|||||||
typedef struct hammy_worker_t hammy_worker_t;
|
typedef struct hammy_worker_t hammy_worker_t;
|
||||||
typedef struct hammy_command_t hammy_command_t;
|
typedef struct hammy_command_t hammy_command_t;
|
||||||
typedef struct hammy_bot_t hammy_bot_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
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#ifndef HAMMY_UTILS_H
|
||||||
|
#define HAMMY_UTILS_H
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// Converts in-place to uppercase
|
||||||
|
void hammy_to_uppercase(char* str);
|
||||||
|
|
||||||
|
// Converts in-place to lowercase
|
||||||
|
void hammy_to_lowercase(char* str);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -5,16 +5,21 @@
|
|||||||
#include <hammy/types.h>
|
#include <hammy/types.h>
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
|
|
||||||
|
#include <hammy/refdb.h>
|
||||||
|
|
||||||
struct hammy_worker_t {
|
struct hammy_worker_t {
|
||||||
pthread_t thread;
|
pthread_t thread;
|
||||||
struct discord* clientCopy; // Clone of the client for concord threading safety - owning
|
struct discord* clientCopy; // Clone of the client for concord threading safety - owning
|
||||||
hammy_pool_t* pool; // Non-owning back-reference
|
hammy_pool_t* pool; // Non-owning back-reference
|
||||||
|
hammy_refdb_t* refdb; // Owning sqlite ref
|
||||||
int id; // Log logging mainly
|
int id; // Log logging mainly
|
||||||
bool started; // For joining
|
bool started; // For joining
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clones client, spawns the thread. Returns false on clone or spawn failure,
|
// Clones client, spawns the thread. Returns false on clone or spawn failure,
|
||||||
// leaving the worker safe to pass to hammy_worker_join().
|
// leaving the worker safe to pass to hammy_worker_join().
|
||||||
|
// Inherits hammy_pool_create()'s precondition: the clone only succeeds from
|
||||||
|
// inside a gateway dispatch callback.
|
||||||
bool hammy_worker_start(hammy_worker_t* worker, hammy_pool_t* pool, struct discord* client, int id);
|
bool hammy_worker_start(hammy_worker_t* worker, hammy_pool_t* pool, struct discord* client, int id);
|
||||||
|
|
||||||
// Joins the thread if it was started and cleans up the clone.
|
// Joins the thread if it was started and cleans up the clone.
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
-- Hammy reference bundle schema
|
||||||
|
--
|
||||||
|
-- Read-only at runtime. Regenerated as a whole file, never migrated in place.
|
||||||
|
-- Open with SQLITE_OPEN_READONLY.
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Bundle metadata
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE ref_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Where each dataset came from and when. Shown by /about and used to decide
|
||||||
|
-- whether a newer bundle is worth downloading.
|
||||||
|
CREATE TABLE ref_sources (
|
||||||
|
dataset TEXT PRIMARY KEY,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
source_url TEXT,
|
||||||
|
license TEXT,
|
||||||
|
retrieved TEXT, -- ISO 8601 date
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Bands and privileges
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Named bands, region-independent. edge_low/high are the widest extent across
|
||||||
|
-- all regions; per-region reality lives in band_segments.
|
||||||
|
CREATE TABLE bands (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE, -- '20m', '70cm'
|
||||||
|
edge_low_hz INTEGER NOT NULL,
|
||||||
|
edge_high_hz INTEGER NOT NULL,
|
||||||
|
wavelength_m REAL,
|
||||||
|
sort_order INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE license_classes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
country TEXT NOT NULL, -- ISO 3166-1 alpha-2
|
||||||
|
code TEXT NOT NULL, -- 'E', 'G', 'T'
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
rank INTEGER NOT NULL, -- higher = more privileges
|
||||||
|
notes TEXT,
|
||||||
|
UNIQUE (country, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One row per (country, class, contiguous frequency range, mode group).
|
||||||
|
-- A class with split phone/CW privileges on a band gets several rows.
|
||||||
|
CREATE TABLE band_segments (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
band_id INTEGER NOT NULL REFERENCES bands(id),
|
||||||
|
country TEXT NOT NULL,
|
||||||
|
iaru_region INTEGER, -- 1, 2, 3, or NULL if not region-scoped
|
||||||
|
class_id INTEGER REFERENCES license_classes(id),
|
||||||
|
low_hz INTEGER NOT NULL,
|
||||||
|
high_hz INTEGER NOT NULL,
|
||||||
|
modes TEXT NOT NULL, -- 'CW', 'CW,DATA', 'PHONE,IMAGE'
|
||||||
|
max_power_w INTEGER, -- NULL = national default
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_band_segments_freq ON band_segments (country, low_hz, high_hz);
|
||||||
|
CREATE INDEX idx_band_segments_class ON band_segments (class_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- DXCC
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- id is the ADIF/ARRL DXCC entity code, supplied by cty2sql.py's DXCC_IDS map
|
||||||
|
-- (cty.dat itself carries no entity numbers). WAE and other non-DXCC entities
|
||||||
|
-- are excluded by default since they have no code.
|
||||||
|
--
|
||||||
|
-- latitude is north-positive and longitude is east-positive; utc_offset is the
|
||||||
|
-- usual "UTC + this = local". All three are flipped relative to cty.dat's own
|
||||||
|
-- positive-west conventions by the importer.
|
||||||
|
CREATE TABLE dxcc_entities (
|
||||||
|
id INTEGER PRIMARY KEY, -- ADIF DXCC entity code
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
primary_prefix TEXT NOT NULL UNIQUE,
|
||||||
|
continent TEXT, -- 'EU', 'NA', ...
|
||||||
|
cq_zone INTEGER, -- record default
|
||||||
|
itu_zone INTEGER, -- record default
|
||||||
|
latitude REAL,
|
||||||
|
longitude REAL,
|
||||||
|
utc_offset REAL,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_dxcc_entities_prefix ON dxcc_entities (primary_prefix);
|
||||||
|
|
||||||
|
-- Prefixes are matched longest-first:
|
||||||
|
-- SELECT ... WHERE ? GLOB prefix || '*' ORDER BY length(prefix) DESC LIMIT 1
|
||||||
|
-- cq_zone/itu_zone here OVERRIDE the entity default when non-NULL. About 75% of
|
||||||
|
-- rows carry one, so COALESCE them in every query:
|
||||||
|
-- COALESCE(p.cq_zone, e.cq_zone)
|
||||||
|
CREATE TABLE dxcc_prefixes (
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
entity_id INTEGER NOT NULL REFERENCES dxcc_entities(id),
|
||||||
|
exact INTEGER NOT NULL DEFAULT 0, -- 1 = whole callsign must match
|
||||||
|
cq_zone INTEGER,
|
||||||
|
itu_zone INTEGER,
|
||||||
|
PRIMARY KEY (prefix, entity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_dxcc_prefixes_len ON dxcc_prefixes (length(prefix) DESC);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Operating reference
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE qcodes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
category TEXT -- 'general', 'aeronautical', 'maritime'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE prosigns (
|
||||||
|
symbol TEXT PRIMARY KEY, -- 'AR', 'SK', 'BT'
|
||||||
|
morse TEXT NOT NULL,
|
||||||
|
meaning TEXT NOT NULL,
|
||||||
|
usage TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE phonetics (
|
||||||
|
letter TEXT PRIMARY KEY,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
pronunciation TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE morse (
|
||||||
|
character TEXT PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL, -- '.-' with . and -
|
||||||
|
category TEXT NOT NULL -- 'letter', 'digit', 'punctuation'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE abbreviations (
|
||||||
|
abbr TEXT PRIMARY KEY,
|
||||||
|
meaning TEXT NOT NULL,
|
||||||
|
context TEXT -- 'CW', 'general', 'contest'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RST readability/strength/tone scale
|
||||||
|
CREATE TABLE rst_scale (
|
||||||
|
component TEXT NOT NULL, -- 'R', 'S', 'T'
|
||||||
|
value INTEGER NOT NULL,
|
||||||
|
meaning TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (component, value)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Engineering reference
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Matched loss follows the usual two-term model:
|
||||||
|
-- loss_dB_per_100ft = k1 * sqrt(f_MHz) + k2 * f_MHz
|
||||||
|
CREATE TABLE coax_types (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
impedance_ohm REAL NOT NULL,
|
||||||
|
velocity_factor REAL NOT NULL,
|
||||||
|
capacitance_pf_per_ft REAL,
|
||||||
|
outer_diameter_mm REAL,
|
||||||
|
loss_k1 REAL,
|
||||||
|
loss_k2 REAL,
|
||||||
|
max_power_hf_w INTEGER,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- NCDXF/IARU beacon network
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- 18 stations, 10 s per slot, 3 min for a full cycle on each of 5 frequencies.
|
||||||
|
-- Which station is on which band at time t is pure arithmetic from slot_index.
|
||||||
|
CREATE TABLE ncdxf_beacons (
|
||||||
|
slot_index INTEGER PRIMARY KEY, -- 0..17, order within the cycle
|
||||||
|
callsign TEXT NOT NULL,
|
||||||
|
location TEXT NOT NULL,
|
||||||
|
grid TEXT,
|
||||||
|
latitude REAL,
|
||||||
|
longitude REAL,
|
||||||
|
dxcc_id INTEGER REFERENCES dxcc_entities(id),
|
||||||
|
active INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ncdxf_frequencies (
|
||||||
|
band_id INTEGER PRIMARY KEY REFERENCES bands(id),
|
||||||
|
freq_hz INTEGER NOT NULL,
|
||||||
|
slot_offset INTEGER NOT NULL -- slots to add for this band
|
||||||
|
);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
INSERT INTO ref_meta (key, value) VALUES
|
||||||
|
('schema_version', '1'),
|
||||||
|
('bundle_version', '2026.08.30-dev'),
|
||||||
|
('generated_at', '2026-08-30'),
|
||||||
|
('project', 'Hammy'),
|
||||||
|
('project_url', 'https://hammybot.org');
|
||||||
|
|
||||||
|
INSERT INTO ref_sources (dataset, source_name, source_url, license, retrieved, notes) VALUES
|
||||||
|
('band_segments', 'FCC Part 97 Subpart D', 'https://www.ecfr.gov/current/title-47/part-97',
|
||||||
|
'US Government work, public domain', NULL,
|
||||||
|
'HAND-ENTERED STARTER DATA. Must be verified against the current Part 97 text before shipping. Wrong band edges can put an operator out of band.'),
|
||||||
|
|
||||||
|
('band_segments_iaru', 'IARU Region 1/2/3 band plans', 'https://www.iaru.org/', 'See IARU', NULL,
|
||||||
|
'Only allocation extents are seeded, not the full mode/bandwidth plans. Incomplete.'),
|
||||||
|
|
||||||
|
('dxcc_entities', 'ARRL DXCC list / cty.dat', 'https://www.country-files.com/',
|
||||||
|
'Free for amateur radio use, see country-files.com', NULL,
|
||||||
|
'DEMO SUBSET ONLY - about 25 entities to exercise the schema. Real bundle must be generated by parsing cty.dat.'),
|
||||||
|
|
||||||
|
('qcodes', 'ITU Q-code series', NULL, 'Public domain', NULL,
|
||||||
|
'Amateur-relevant subset of the QRA-QUZ series.'),
|
||||||
|
|
||||||
|
('phonetics', 'ITU/NATO phonetic alphabet', NULL, 'Public domain', NULL, NULL),
|
||||||
|
|
||||||
|
('morse', 'ITU-R M.1677-1', NULL, 'Public domain', NULL,
|
||||||
|
'International Morse. Does not include non-Latin extensions.'),
|
||||||
|
|
||||||
|
('coax_types', 'Manufacturer datasheets', NULL, 'Various', NULL,
|
||||||
|
'Impedance, velocity factor and capacitance are nominal published figures. loss_k1/loss_k2 are deliberately NULL - populate from the actual datasheet for each cable rather than a generic approximation, since loss is what the calculator returns.'),
|
||||||
|
|
||||||
|
('ncdxf_beacons', 'NCDXF/IARU International Beacon Project', 'https://www.ncdxf.org/beacon/',
|
||||||
|
'See NCDXF', NULL,
|
||||||
|
'Station order is stable; individual beacons go offline for extended periods. The active flag needs refreshing against the NCDXF status page at build time.');
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Bands
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO bands (id, name, edge_low_hz, edge_high_hz, wavelength_m, sort_order) VALUES
|
||||||
|
(1, '2200m', 135700, 137800, 2200, 10),
|
||||||
|
(2, '630m', 472000, 479000, 630, 20),
|
||||||
|
(3, '160m', 1800000, 2000000, 160, 30),
|
||||||
|
(4, '80m', 3500000, 4000000, 80, 40),
|
||||||
|
(5, '60m', 5330500, 5406400, 60, 50),
|
||||||
|
(6, '40m', 7000000, 7300000, 40, 60),
|
||||||
|
(7, '30m', 10100000, 10150000, 30, 70),
|
||||||
|
(8, '20m', 14000000, 14350000, 20, 80),
|
||||||
|
(9, '17m', 18068000, 18168000, 17, 90),
|
||||||
|
(10, '15m', 21000000, 21450000, 15, 100),
|
||||||
|
(11, '12m', 24890000, 24990000, 12, 110),
|
||||||
|
(12, '10m', 28000000, 29700000, 10, 120),
|
||||||
|
(13, '6m', 50000000, 54000000, 6, 130),
|
||||||
|
(14, '2m', 144000000, 148000000, 2, 140),
|
||||||
|
(15, '1.25m', 222000000, 225000000, 1.25, 150),
|
||||||
|
(16, '70cm', 420000000, 450000000, 0.70, 160),
|
||||||
|
(17, '33cm', 902000000, 928000000, 0.33, 170),
|
||||||
|
(18, '23cm', 1240000000, 1300000000, 0.23, 180);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- US license classes
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO license_classes (id, country, code, name, rank, notes) VALUES
|
||||||
|
(1, 'US', 'E', 'Amateur Extra', 50, NULL),
|
||||||
|
(2, 'US', 'A', 'Advanced', 40, 'Closed to new issue since 1 April 2000; existing licences remain valid.'),
|
||||||
|
(3, 'US', 'G', 'General', 30, NULL),
|
||||||
|
(4, 'US', 'T', 'Technician', 20, NULL),
|
||||||
|
(5, 'US', 'N', 'Novice', 10, 'Closed to new issue since 1 April 2000; existing licences remain valid.');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- US band segments (47 CFR Part 97)
|
||||||
|
--
|
||||||
|
-- VERIFY BEFORE SHIPPING. Hand-entered from the Part 97 privilege tables.
|
||||||
|
-- Wrong edges or wrong privileges can put an operator outside their licence.
|
||||||
|
--
|
||||||
|
-- Two shapes are used here, deliberately:
|
||||||
|
--
|
||||||
|
-- 1. Bands where every eligible class gets the SAME range are generated with
|
||||||
|
-- INSERT..SELECT over license_classes and a rank threshold. The rule is
|
||||||
|
-- stated once, in the WHERE clause, and the rows follow from it.
|
||||||
|
--
|
||||||
|
-- 2. Bands where classes get GENUINELY DIFFERENT ranges (most of HF: Extra
|
||||||
|
-- has 14.000-14.150 where General has 14.025-14.150) are written out
|
||||||
|
-- explicitly, one row per class. A rank threshold cannot express that.
|
||||||
|
--
|
||||||
|
-- Shape 1 exists because hand-enumerating six bands times five classes is
|
||||||
|
-- thirty near-identical rows, and the first version of this file missed several
|
||||||
|
-- of them - Advanced was absent from 2200m, 630m and 60m entirely, and VHF/UHF
|
||||||
|
-- was seeded for Technician only, so an Extra was told they had no 2m access.
|
||||||
|
-- audit.py's privilege-nesting check catches that class of mistake now, but not
|
||||||
|
-- making it is better.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- 2200m and 630m: all classes. Strict EIRP limits, and the operator must notify
|
||||||
|
-- UTC and await a response before transmitting.
|
||||||
|
-- VERIFY: whether Novice holds these is the part I am least sure of.
|
||||||
|
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
|
||||||
|
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, v.modes, v.max_power_w, v.notes
|
||||||
|
FROM license_classes lc
|
||||||
|
JOIN (
|
||||||
|
SELECT 1 AS band_id, 135700 AS low_hz, 137800 AS high_hz,
|
||||||
|
'CW,DATA' AS modes, NULL AS max_power_w, '1 W EIRP maximum' AS notes
|
||||||
|
UNION SELECT 2, 472000, 479000, 'CW,DATA', NULL, '5 W EIRP maximum'
|
||||||
|
) v
|
||||||
|
WHERE lc.country = 'US';
|
||||||
|
|
||||||
|
-- 160m, 30m, 17m, 12m: General and above (rank >= 30), whole band, same for all.
|
||||||
|
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
|
||||||
|
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, v.modes, v.max_power_w, v.notes
|
||||||
|
FROM license_classes lc
|
||||||
|
JOIN (
|
||||||
|
SELECT 3 AS band_id, 1800000 AS low_hz, 2000000 AS high_hz,
|
||||||
|
'CW,DATA,PHONE,IMAGE' AS modes, NULL AS max_power_w, NULL AS notes
|
||||||
|
UNION SELECT 7, 10100000, 10150000, 'CW,DATA', 200, 'No phone or image permitted'
|
||||||
|
UNION SELECT 9, 18068000, 18110000, 'CW,DATA', NULL, NULL
|
||||||
|
UNION SELECT 9, 18110000, 18168000, 'PHONE,IMAGE', NULL, NULL
|
||||||
|
UNION SELECT 11, 24890000, 24930000, 'CW,DATA', NULL, NULL
|
||||||
|
UNION SELECT 11, 24930000, 24990000, 'PHONE,IMAGE', NULL, NULL
|
||||||
|
) v
|
||||||
|
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 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
|
||||||
|
JOIN (
|
||||||
|
SELECT 5330500 AS low_hz, 5333300 AS high_hz, 'Channel 1, 100 W ERP, USB' AS notes
|
||||||
|
UNION SELECT 5346500, 5349300, 'Channel 2, 100 W ERP, USB'
|
||||||
|
UNION SELECT 5357000, 5359800, 'Channel 3, 100 W ERP, USB'
|
||||||
|
UNION SELECT 5371500, 5374300, 'Channel 4, 100 W ERP, USB'
|
||||||
|
UNION SELECT 5403500, 5406300, 'Channel 5, 100 W ERP, USB'
|
||||||
|
) v
|
||||||
|
WHERE lc.country = 'US' AND lc.rank >= 30;
|
||||||
|
|
||||||
|
-- VHF/UHF and above: Technician and higher (rank >= 20) hold the full
|
||||||
|
-- allocation. There is no per-class variation above 50 MHz except for Novice.
|
||||||
|
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
|
||||||
|
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, v.modes, v.max_power_w, v.notes
|
||||||
|
FROM license_classes lc
|
||||||
|
JOIN (
|
||||||
|
SELECT 13 AS band_id, 50000000 AS low_hz, 50100000 AS high_hz,
|
||||||
|
'CW' AS modes, NULL AS max_power_w, 'CW only below 50.1 MHz' AS notes
|
||||||
|
UNION SELECT 13, 50100000, 54000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
|
||||||
|
UNION SELECT 14, 144000000, 144100000, 'CW', NULL, 'CW only below 144.1 MHz'
|
||||||
|
UNION SELECT 14, 144100000, 148000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
|
||||||
|
UNION SELECT 15, 222000000, 225000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
|
||||||
|
UNION SELECT 16, 420000000, 450000000, 'CW,DATA,PHONE,IMAGE', NULL,
|
||||||
|
'Geographic restrictions apply near some radar sites'
|
||||||
|
UNION SELECT 17, 902000000, 928000000, 'CW,DATA,PHONE,IMAGE', NULL,
|
||||||
|
'Secondary allocation, shared with Part 15 devices'
|
||||||
|
UNION SELECT 18, 1240000000, 1300000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
|
||||||
|
) v
|
||||||
|
WHERE lc.country = 'US' AND lc.rank >= 20;
|
||||||
|
|
||||||
|
-- Novice above 50 MHz: two reduced-power slices and nothing else.
|
||||||
|
-- VERIFY: Novice licences have not been issued since 2000, so an error here
|
||||||
|
-- would go unnoticed for years.
|
||||||
|
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
|
||||||
|
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, 'CW,DATA,PHONE,IMAGE', v.max_power_w,
|
||||||
|
'Novice segment, reduced power'
|
||||||
|
FROM license_classes lc
|
||||||
|
JOIN (
|
||||||
|
SELECT 15 AS band_id, 222000000 AS low_hz, 225000000 AS high_hz, 25 AS max_power_w
|
||||||
|
UNION SELECT 18, 1270000000, 1295000000, 5
|
||||||
|
) v
|
||||||
|
WHERE lc.country = 'US' AND lc.code = 'N';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Bands where classes get genuinely different ranges. Written out explicitly:
|
||||||
|
-- a rank threshold cannot express "Extra from 3.500, General from 3.525".
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- 80m / 75m
|
||||||
|
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
|
||||||
|
(4, 'US', 1, 3500000, 3600000, 'CW,DATA', NULL, NULL),
|
||||||
|
(4, 'US', 1, 3600000, 4000000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(4, 'US', 2, 3525000, 3600000, 'CW,DATA', NULL, NULL),
|
||||||
|
(4, 'US', 2, 3700000, 4000000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(4, 'US', 3, 3525000, 3600000, 'CW,DATA', NULL, NULL),
|
||||||
|
(4, 'US', 3, 3800000, 4000000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(4, 'US', 4, 3525000, 3600000, 'CW', 200, 'CW only'),
|
||||||
|
(4, 'US', 5, 3525000, 3600000, 'CW', 200, 'CW only');
|
||||||
|
|
||||||
|
-- 40m
|
||||||
|
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
|
||||||
|
(6, 'US', 1, 7000000, 7125000, 'CW,DATA', NULL, NULL),
|
||||||
|
(6, 'US', 1, 7125000, 7300000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(6, 'US', 2, 7025000, 7125000, 'CW,DATA', NULL, NULL),
|
||||||
|
(6, 'US', 2, 7125000, 7300000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(6, 'US', 3, 7025000, 7125000, 'CW,DATA', NULL, NULL),
|
||||||
|
(6, 'US', 3, 7175000, 7300000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(6, 'US', 4, 7025000, 7125000, 'CW', 200, 'CW only'),
|
||||||
|
(6, 'US', 5, 7025000, 7125000, 'CW', 200, 'CW only');
|
||||||
|
|
||||||
|
-- 20m
|
||||||
|
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
|
||||||
|
(8, 'US', 1, 14000000, 14150000, 'CW,DATA', NULL, NULL),
|
||||||
|
(8, 'US', 1, 14150000, 14350000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(8, 'US', 2, 14025000, 14150000, 'CW,DATA', NULL, NULL),
|
||||||
|
(8, 'US', 2, 14175000, 14350000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(8, 'US', 3, 14025000, 14150000, 'CW,DATA', NULL, NULL),
|
||||||
|
(8, 'US', 3, 14225000, 14350000, 'PHONE,IMAGE', NULL, NULL);
|
||||||
|
|
||||||
|
-- 15m
|
||||||
|
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
|
||||||
|
(10, 'US', 1, 21000000, 21200000, 'CW,DATA', NULL, NULL),
|
||||||
|
(10, 'US', 1, 21200000, 21450000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(10, 'US', 2, 21025000, 21200000, 'CW,DATA', NULL, NULL),
|
||||||
|
(10, 'US', 2, 21225000, 21450000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(10, 'US', 3, 21025000, 21200000, 'CW,DATA', NULL, NULL),
|
||||||
|
(10, 'US', 3, 21275000, 21450000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(10, 'US', 4, 21025000, 21200000, 'CW', 200, 'CW only'),
|
||||||
|
(10, 'US', 5, 21025000, 21200000, 'CW', 200, 'CW only');
|
||||||
|
|
||||||
|
-- 10m: the only HF band with Technician phone privileges
|
||||||
|
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
|
||||||
|
(12, 'US', 1, 28000000, 28300000, 'CW,DATA', NULL, NULL),
|
||||||
|
(12, 'US', 1, 28300000, 29700000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(12, 'US', 2, 28000000, 28300000, 'CW,DATA', NULL, NULL),
|
||||||
|
(12, 'US', 2, 28300000, 29700000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(12, 'US', 3, 28000000, 28300000, 'CW,DATA', NULL, NULL),
|
||||||
|
(12, 'US', 3, 28300000, 29700000, 'PHONE,IMAGE', NULL, NULL),
|
||||||
|
(12, 'US', 4, 28000000, 28300000, 'CW,DATA', 200, NULL),
|
||||||
|
(12, 'US', 4, 28300000, 28500000, 'PHONE', 200, 'Technician phone privileges'),
|
||||||
|
(12, 'US', 5, 28100000, 28300000, 'CW,DATA', 200, NULL),
|
||||||
|
(12, 'US', 5, 28300000, 28500000, 'PHONE', 200, NULL);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- IARU allocation extents (class_id NULL = the allocation, not privileges)
|
||||||
|
-- Partial. Enough to answer "is 7.250 legal here" for Region 1 vs 2.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO band_segments (band_id, country, iaru_region, class_id, low_hz, high_hz, modes, notes) VALUES
|
||||||
|
(3, '', 1, NULL, 1810000, 2000000, 'ALL', 'Varies by country within R1'),
|
||||||
|
(4, '', 1, NULL, 3500000, 3800000, 'ALL', NULL),
|
||||||
|
(6, '', 1, NULL, 7000000, 7200000, 'ALL', NULL),
|
||||||
|
(8, '', 1, NULL, 14000000, 14350000, 'ALL', NULL),
|
||||||
|
(10, '', 1, NULL, 21000000, 21450000, 'ALL', NULL),
|
||||||
|
(12, '', 1, NULL, 28000000, 29700000, 'ALL', NULL),
|
||||||
|
(14, '', 1, NULL, 144000000, 146000000, 'ALL', NULL),
|
||||||
|
(16, '', 1, NULL, 430000000, 440000000, 'ALL', 'Varies by country within R1'),
|
||||||
|
(4, '', 3, NULL, 3500000, 3900000, 'ALL', 'Varies by country within R3'),
|
||||||
|
(6, '', 3, NULL, 7000000, 7200000, 'ALL', NULL),
|
||||||
|
(14, '', 3, NULL, 144000000, 148000000, 'ALL', NULL);
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Morse (ITU-R M.1677-1)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO morse (character, code, category) VALUES
|
||||||
|
('A', '.-', 'letter'), ('B', '-...', 'letter'), ('C', '-.-.', 'letter'),
|
||||||
|
('D', '-..', 'letter'), ('E', '.', 'letter'), ('F', '..-.', 'letter'),
|
||||||
|
('G', '--.', 'letter'), ('H', '....', 'letter'), ('I', '..', 'letter'),
|
||||||
|
('J', '.---', 'letter'), ('K', '-.-', 'letter'), ('L', '.-..', 'letter'),
|
||||||
|
('M', '--', 'letter'), ('N', '-.', 'letter'), ('O', '---', 'letter'),
|
||||||
|
('P', '.--.', 'letter'), ('Q', '--.-', 'letter'), ('R', '.-.', 'letter'),
|
||||||
|
('S', '...', 'letter'), ('T', '-', 'letter'), ('U', '..-', 'letter'),
|
||||||
|
('V', '...-', 'letter'), ('W', '.--', 'letter'), ('X', '-..-', 'letter'),
|
||||||
|
('Y', '-.--', 'letter'), ('Z', '--..', 'letter'),
|
||||||
|
('0', '-----', 'digit'), ('1', '.----', 'digit'), ('2', '..---', 'digit'),
|
||||||
|
('3', '...--', 'digit'), ('4', '....-', 'digit'), ('5', '.....', 'digit'),
|
||||||
|
('6', '-....', 'digit'), ('7', '--...', 'digit'), ('8', '---..', 'digit'),
|
||||||
|
('9', '----.', 'digit'),
|
||||||
|
('.', '.-.-.-', 'punctuation'), (',', '--..--', 'punctuation'),
|
||||||
|
('?', '..--..', 'punctuation'), ('''', '.----.', 'punctuation'),
|
||||||
|
('!', '-.-.--', 'punctuation'), ('/', '-..-.', 'punctuation'),
|
||||||
|
('(', '-.--.', 'punctuation'), (')', '-.--.-', 'punctuation'),
|
||||||
|
('&', '.-...', 'punctuation'), (':', '---...', 'punctuation'),
|
||||||
|
(';', '-.-.-.', 'punctuation'), ('=', '-...-', 'punctuation'),
|
||||||
|
('+', '.-.-.', 'punctuation'), ('-', '-....-', 'punctuation'),
|
||||||
|
('_', '..--.-', 'punctuation'), ('"', '.-..-.', 'punctuation'),
|
||||||
|
('$', '...-..-','punctuation'), ('@', '.--.-.', 'punctuation');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ITU/NATO phonetics
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO phonetics (letter, word, pronunciation) VALUES
|
||||||
|
('A', 'Alfa', 'AL-FAH'), ('B', 'Bravo', 'BRAH-VOH'),
|
||||||
|
('C', 'Charlie', 'CHAR-LEE'), ('D', 'Delta', 'DELL-TAH'),
|
||||||
|
('E', 'Echo', 'ECK-OH'), ('F', 'Foxtrot', 'FOKS-TROT'),
|
||||||
|
('G', 'Golf', 'GOLF'), ('H', 'Hotel', 'HOH-TELL'),
|
||||||
|
('I', 'India', 'IN-DEE-AH'), ('J', 'Juliett', 'JEW-LEE-ETT'),
|
||||||
|
('K', 'Kilo', 'KEY-LOH'), ('L', 'Lima', 'LEE-MAH'),
|
||||||
|
('M', 'Mike', 'MIKE'), ('N', 'November', 'NO-VEM-BER'),
|
||||||
|
('O', 'Oscar', 'OSS-CAH'), ('P', 'Papa', 'PAH-PAH'),
|
||||||
|
('Q', 'Quebec', 'KEH-BECK'), ('R', 'Romeo', 'ROW-ME-OH'),
|
||||||
|
('S', 'Sierra', 'SEE-AIR-RAH'), ('T', 'Tango', 'TANG-GO'),
|
||||||
|
('U', 'Uniform', 'YOU-NEE-FORM'),('V', 'Victor', 'VIK-TAH'),
|
||||||
|
('W', 'Whiskey', 'WISS-KEY'), ('X', 'X-ray', 'ECKS-RAY'),
|
||||||
|
('Y', 'Yankee', 'YANG-KEY'), ('Z', 'Zulu', 'ZOO-LOO'),
|
||||||
|
('0', 'Zero', 'ZEE-RO'), ('1', 'One', 'WUN'),
|
||||||
|
('2', 'Two', 'TOO'), ('3', 'Three', 'TREE'),
|
||||||
|
('4', 'Four', 'FOW-ER'), ('5', 'Five', 'FIFE'),
|
||||||
|
('6', 'Six', 'SIX'), ('7', 'Seven', 'SEV-EN'),
|
||||||
|
('8', 'Eight', 'AIT'), ('9', 'Nine', 'NIN-ER');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Q-codes (amateur-relevant subset)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO qcodes (code, question, answer, category) VALUES
|
||||||
|
('QRA', 'What is the name of your station?', 'The name of my station is ...', 'general'),
|
||||||
|
('QRG', 'Will you tell me my exact frequency?', 'Your exact frequency is ... kHz', 'general'),
|
||||||
|
('QRK', 'What is the readability of my signals?', 'The readability of your signals is ... (1 to 5)', 'general'),
|
||||||
|
('QRL', 'Are you busy?', 'I am busy, please do not interfere', 'general'),
|
||||||
|
('QRM', 'Is my transmission being interfered with?', 'Your transmission is being interfered with', 'general'),
|
||||||
|
('QRN', 'Are you troubled by static?', 'I am troubled by static', 'general'),
|
||||||
|
('QRO', 'Shall I increase transmit power?', 'Increase transmit power', 'general'),
|
||||||
|
('QRP', 'Shall I decrease transmit power?', 'Decrease transmit power', 'general'),
|
||||||
|
('QRQ', 'Shall I send faster?', 'Send faster (... words per minute)', 'general'),
|
||||||
|
('QRS', 'Shall I send more slowly?', 'Send more slowly (... words per minute)', 'general'),
|
||||||
|
('QRT', 'Shall I stop sending?', 'Stop sending', 'general'),
|
||||||
|
('QRU', 'Have you anything for me?', 'I have nothing for you', 'general'),
|
||||||
|
('QRV', 'Are you ready?', 'I am ready', 'general'),
|
||||||
|
('QRX', 'When will you call me again?', 'I will call you again at ...', 'general'),
|
||||||
|
('QRZ', 'Who is calling me?', 'You are being called by ...', 'general'),
|
||||||
|
('QSA', 'What is the strength of my signals?', 'The strength of your signals is ... (1 to 5)', 'general'),
|
||||||
|
('QSB', 'Are my signals fading?', 'Your signals are fading', 'general'),
|
||||||
|
('QSK', 'Can you hear me between your signals?', 'I can hear you between my signals', 'general'),
|
||||||
|
('QSL', 'Can you acknowledge receipt?', 'I acknowledge receipt', 'general'),
|
||||||
|
('QSO', 'Can you communicate with ... directly?', 'I can communicate with ... directly', 'general'),
|
||||||
|
('QSP', 'Will you relay to ...?', 'I will relay to ...', 'general'),
|
||||||
|
('QSY', 'Shall I change frequency?', 'Change frequency to ...', 'general'),
|
||||||
|
('QTC', 'How many messages have you to send?', 'I have ... messages for you', 'general'),
|
||||||
|
('QTH', 'What is your position?', 'My position is ...', 'general'),
|
||||||
|
('QTR', 'What is the correct time?', 'The correct time is ...', 'general');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Prosigns
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO prosigns (symbol, morse, meaning, usage) VALUES
|
||||||
|
('AR', '.-.-.', 'End of message', 'Sent at the end of a transmission to a specific station'),
|
||||||
|
('AS', '.-...', 'Wait / stand by', 'Asking the other station to hold'),
|
||||||
|
('BK', '-...-.-', 'Break', 'Interrupting to hand over quickly'),
|
||||||
|
('BT', '-...-', 'Separator', 'Between paragraphs or sections; sent as a long dash'),
|
||||||
|
('CL', '-.-..-..', 'Closing station', 'Going off the air entirely'),
|
||||||
|
('CT', '-.-.-', 'Attention / start', 'Marks the beginning of a transmission'),
|
||||||
|
('HH', '........', 'Error', 'Eight dits, meaning the last word was wrong'),
|
||||||
|
('KN', '-.--.', 'Go ahead, named station only', 'Invites only the station being worked to reply'),
|
||||||
|
('SK', '...-.-', 'End of contact', 'Final transmission of a QSO'),
|
||||||
|
('SN', '...-.', 'Understood', 'Also written VE'),
|
||||||
|
('SOS', '...---...', 'Distress', 'International distress signal; sent as one symbol');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- CW abbreviations
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO abbreviations (abbr, meaning, context) VALUES
|
||||||
|
('73', 'Best regards', 'general'),
|
||||||
|
('88', 'Love and kisses', 'general'),
|
||||||
|
('ABT', 'About', 'CW'),
|
||||||
|
('AGN', 'Again', 'CW'),
|
||||||
|
('ANT', 'Antenna', 'CW'),
|
||||||
|
('BURO', 'QSL bureau', 'general'),
|
||||||
|
('CFM', 'Confirm', 'CW'),
|
||||||
|
('CQ', 'Calling any station', 'general'),
|
||||||
|
('CUL', 'See you later', 'CW'),
|
||||||
|
('DE', 'From (this is)', 'CW'),
|
||||||
|
('DX', 'Distance / long-distance station', 'general'),
|
||||||
|
('ES', 'And', 'CW'),
|
||||||
|
('FB', 'Fine business (excellent)', 'CW'),
|
||||||
|
('GA', 'Good afternoon / go ahead', 'CW'),
|
||||||
|
('GE', 'Good evening', 'CW'),
|
||||||
|
('GM', 'Good morning', 'CW'),
|
||||||
|
('HI', 'Laughter', 'CW'),
|
||||||
|
('HW', 'How copy?', 'CW'),
|
||||||
|
('K', 'Invitation to transmit', 'CW'),
|
||||||
|
('OM', 'Old man (any operator)', 'CW'),
|
||||||
|
('PSE', 'Please', 'CW'),
|
||||||
|
('PWR', 'Power', 'CW'),
|
||||||
|
('RIG', 'Station equipment', 'general'),
|
||||||
|
('RPT', 'Repeat', 'CW'),
|
||||||
|
('RST', 'Readability, strength, tone', 'general'),
|
||||||
|
('RX', 'Receiver', 'general'),
|
||||||
|
('SIG', 'Signal', 'CW'),
|
||||||
|
('SK', 'Silent key (deceased operator)', 'general'),
|
||||||
|
('SRI', 'Sorry', 'CW'),
|
||||||
|
('TNX', 'Thanks', 'CW'),
|
||||||
|
('TU', 'Thank you', 'CW'),
|
||||||
|
('TX', 'Transmitter', 'general'),
|
||||||
|
('UR', 'Your / you are', 'CW'),
|
||||||
|
('VY', 'Very', 'CW'),
|
||||||
|
('WX', 'Weather', 'CW'),
|
||||||
|
('XYL', 'Wife', 'CW'),
|
||||||
|
('YL', 'Young lady (female operator)','CW');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- RST scale
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO rst_scale (component, value, meaning) VALUES
|
||||||
|
('R', 1, 'Unreadable'),
|
||||||
|
('R', 2, 'Barely readable, occasional words distinguishable'),
|
||||||
|
('R', 3, 'Readable with considerable difficulty'),
|
||||||
|
('R', 4, 'Readable with practically no difficulty'),
|
||||||
|
('R', 5, 'Perfectly readable'),
|
||||||
|
('S', 1, 'Faint, signals barely perceptible'),
|
||||||
|
('S', 2, 'Very weak'),
|
||||||
|
('S', 3, 'Weak'),
|
||||||
|
('S', 4, 'Fair'),
|
||||||
|
('S', 5, 'Fairly good'),
|
||||||
|
('S', 6, 'Good'),
|
||||||
|
('S', 7, 'Moderately strong'),
|
||||||
|
('S', 8, 'Strong'),
|
||||||
|
('S', 9, 'Extremely strong'),
|
||||||
|
('T', 1, 'Extremely rough hissing note'),
|
||||||
|
('T', 2, 'Very rough AC note, no trace of musicality'),
|
||||||
|
('T', 3, 'Rough, low-pitched AC note, slightly musical'),
|
||||||
|
('T', 4, 'Rather rough AC note, moderately musical'),
|
||||||
|
('T', 5, 'Musically modulated note'),
|
||||||
|
('T', 6, 'Modulated note, slight trace of whistle'),
|
||||||
|
('T', 7, 'Near DC note, smooth ripple'),
|
||||||
|
('T', 8, 'Good DC note, trace of ripple'),
|
||||||
|
('T', 9, 'Purest DC note');
|
||||||
+1962
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Coax
|
||||||
|
--
|
||||||
|
-- Impedance, velocity factor and capacitance are nominal published figures.
|
||||||
|
-- loss_k1/loss_k2 are intentionally NULL: matched loss varies enough between
|
||||||
|
-- manufacturers of nominally the same cable that a generic approximation would
|
||||||
|
-- give the calculator false precision. Populate from the datasheet of the cable
|
||||||
|
-- you actually mean.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO coax_types (name, impedance_ohm, velocity_factor, capacitance_pf_per_ft, outer_diameter_mm, loss_k1, loss_k2, notes) VALUES
|
||||||
|
('RG-58C/U', 50.0, 0.66, 30.8, 4.95, NULL, NULL, 'Thin, lossy, common for short VHF jumpers'),
|
||||||
|
('RG-8X', 50.0, 0.82, 25.0, 6.10, NULL, NULL, 'Foam dielectric, a middle ground'),
|
||||||
|
('RG-213/U', 50.0, 0.66, 30.8, 10.30, NULL, NULL, 'Workhorse HF cable'),
|
||||||
|
('RG-214/U', 50.0, 0.66, 30.8, 10.80, NULL, NULL, 'Double-shielded RG-213 equivalent'),
|
||||||
|
('LMR-400', 50.0, 0.85, 23.9, 10.29, NULL, NULL, 'Low loss, common for VHF/UHF runs'),
|
||||||
|
('LMR-600', 50.0, 0.87, 23.4, 14.99, NULL, NULL, 'Lower loss, stiffer'),
|
||||||
|
('RG-6/U', 75.0, 0.83, 16.2, 6.90, NULL, NULL, '75 ohm, cheap, fine for receive'),
|
||||||
|
('RG-11/U', 75.0, 0.66, 20.6, 10.30, NULL, NULL, '75 ohm, lower loss than RG-6'),
|
||||||
|
('RG-174/U', 50.0, 0.66, 30.8, 2.55, NULL, NULL, 'Very thin, very lossy, patch use only'),
|
||||||
|
('Belden 9913', 50.0, 0.84, 24.6, 10.30, NULL, NULL, 'Air dielectric, needs care against water ingress'),
|
||||||
|
('Hardline LDF4-50A', 50.0, 0.88, 25.9, 15.90, NULL, NULL, '1/2 inch corrugated hardline');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- NCDXF/IARU beacons
|
||||||
|
--
|
||||||
|
-- 18 stations, one 10 s slot each, 180 s for a full cycle per band.
|
||||||
|
-- Station transmitting on a given band at UTC time t:
|
||||||
|
-- slot = (floor(t / 10) - slot_offset) mod 18
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO ncdxf_beacons (slot_index, callsign, location, grid, latitude, longitude, dxcc_id) VALUES
|
||||||
|
(0, '4U1UN', 'United Nations, New York', 'FN30AS', 40.75, -73.97, NULL),
|
||||||
|
(1, 'VE8AT', 'Inuvik, NT, Canada', 'CP38GJ', 68.38, -133.72, 1),
|
||||||
|
(2, 'W6WX', 'Mt Umunhum, CA, USA', 'CM97BD', 37.16, -121.90, 291),
|
||||||
|
(3, 'KH6RS', 'Maui, Hawaii', 'BL10TS', 20.79, -156.46, NULL),
|
||||||
|
(4, 'ZL6B', 'Masterton, New Zealand', 'RE78TW', -40.92, 175.61, 170),
|
||||||
|
(5, 'VK6RBP', 'Rolystone, WA, Australia', 'OF87AV', -32.11, 116.05, 150),
|
||||||
|
(6, 'JA2IGY', 'Mt Asama, Japan', 'PM84JK', 34.45, 136.79, 339),
|
||||||
|
(7, 'RR9O', 'Novosibirsk, Russia', 'NO14KX', 54.98, 82.89, NULL),
|
||||||
|
(8, 'VR2B', 'Hong Kong', 'OL72BG', 22.28, 114.16, NULL),
|
||||||
|
(9, '4S7B', 'Colombo, Sri Lanka', 'MJ96WV', 6.91, 79.87, NULL),
|
||||||
|
(10, 'ZS6DN', 'Pretoria, South Africa', 'KG33XI', -25.90, 28.26, 462),
|
||||||
|
(11, '5Z4B', 'Kariobangi, Kenya', 'KI88HR', -1.24, 36.89, NULL),
|
||||||
|
(12, '4X6TU', 'Tel Aviv, Israel', 'KM72JB', 32.05, 34.78, NULL),
|
||||||
|
(13, 'OH2B', 'Lohja, Finland', 'KP20EH', 60.25, 24.40, 224),
|
||||||
|
(14, 'CS3B', 'Madeira', 'IM12JT', 32.72, -16.99, NULL),
|
||||||
|
(15, 'LU4AA', 'Buenos Aires, Argentina', 'GF05TJ', -34.62, -58.37, 100),
|
||||||
|
(16, 'OA4B', 'Lima, Peru', 'FH17MW', -12.07, -77.03, NULL),
|
||||||
|
(17, 'YV5B', 'Caracas, Venezuela', 'FJ69CC', 10.50, -66.92, NULL);
|
||||||
|
|
||||||
|
INSERT INTO ncdxf_frequencies (band_id, freq_hz, slot_offset) VALUES
|
||||||
|
(8, 14100000, 0),
|
||||||
|
(9, 18110000, 1),
|
||||||
|
(10, 21150000, 2),
|
||||||
|
(11, 24930000, 3),
|
||||||
|
(12, 28200000, 4);
|
||||||
+3661
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
-- Generated by audit.py --indexes. Runs last in the build.
|
||||||
|
-- The bundle is read-only, so indexes cost file size and nothing else.
|
||||||
|
|
||||||
|
-- candidate-prefix equality lookup; leading PK column, but stated explicitly because everything depends on it
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dxcc_prefixes_prefix ON "dxcc_prefixes" ("prefix");
|
||||||
|
-- joins back to dxcc_entities
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dxcc_prefixes_entity_id ON "dxcc_prefixes" ("entity_id");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ANALYZE;
|
||||||
Executable
+342
@@ -0,0 +1,342 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
adif2sql.py -- fetch the current ADIF release's exported data files and turn the
|
||||||
|
enumerations into SQL for the Hammy reference bundle.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 adif2sql.py --list # show what the zip contains
|
||||||
|
python3 adif2sql.py -o 07-adif.sql # fetch, parse, emit SQL
|
||||||
|
python3 adif2sql.py --zip local.zip --list # work from an already-downloaded zip
|
||||||
|
python3 adif2sql.py --version 316 -o out.sql
|
||||||
|
|
||||||
|
How the site works
|
||||||
|
------------------
|
||||||
|
* https://adif.org.uk/adiflatestrelease.txt returns the release as three
|
||||||
|
digits, e.g. "317" meaning ADIF 3.1.7.
|
||||||
|
* Most filenames can be constructed from that: 317/adx317.xsd and so on.
|
||||||
|
* The resources zip CANNOT: its name embeds a release date that is not derivable
|
||||||
|
from the version number (ADIF_317_resources_2026_03_22.zip). So this script
|
||||||
|
scrapes /317/index.htm to find it rather than guessing.
|
||||||
|
* The hosting provider blocks unrecognised User-Agent strings. Python's default
|
||||||
|
("Python-urllib/3.x") is one of the blocked ones, so a UA is set explicitly
|
||||||
|
below. Do not remove it.
|
||||||
|
|
||||||
|
Be polite: the ADIF site asks that applications download and keep local copies
|
||||||
|
rather than fetching on every run. This script is a build-time tool, not a
|
||||||
|
runtime dependency - run it when you cut a new bundle, commit the output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import date, timezone
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
BASE = "https://adif.org.uk"
|
||||||
|
LATEST_URL = BASE + "/adiflatestrelease.txt"
|
||||||
|
|
||||||
|
# The hosting provider blocks Python's default UA. Any plausible string works;
|
||||||
|
# this one identifies the tool honestly, which is the polite option.
|
||||||
|
USER_AGENT = "Hammy-refbundle/1.0 (+https://hammybot.org)"
|
||||||
|
|
||||||
|
RETRIEVED = date.today().isoformat()
|
||||||
|
|
||||||
|
# The resources zip ships every enumeration in six formats:
|
||||||
|
# exports/{csv,json,ods,tsv,xlsx,xml}/enumerations_<name>.<ext>
|
||||||
|
# Only one text format is wanted. ods and xlsx are themselves zip archives and
|
||||||
|
# json/xml are not delimited, so feeding them to a CSV reader produces garbage.
|
||||||
|
FORMATS = ("tsv", "csv")
|
||||||
|
|
||||||
|
# Members are matched with an anchored pattern rather than a substring test.
|
||||||
|
# Substring matching collapsed enumerations_mode, enumerations_submode and
|
||||||
|
# enumerations_propagation_mode into one table.
|
||||||
|
def member_pattern(fmt):
|
||||||
|
return re.compile(
|
||||||
|
rf"(?:^|/)exports/{fmt}/enumerations_(?P<name>[A-Za-z0-9_]+)\.{fmt}$",
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
# Optional prettier names. Anything not listed becomes adif_<enumeration_name>.
|
||||||
|
TABLE_ALIASES = {
|
||||||
|
"dxcc_entity_code": "adif_dxcc",
|
||||||
|
"primary_administrative_subdivision": "adif_subdivisions",
|
||||||
|
"secondary_administrative_subdivision": "adif_subdivisions_secondary",
|
||||||
|
"secondary_administrative_subdivision_alt": "adif_subdivisions_secondary_alt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def table_for(enum_name):
|
||||||
|
key = enum_name.lower()
|
||||||
|
|
||||||
|
return TABLE_ALIASES.get(key, "adif_" + key)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url, binary=False):
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
data = resp.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code == 403:
|
||||||
|
sys.exit(f"403 from {url} - the User-Agent is being blocked. "
|
||||||
|
f"Current UA: {USER_AGENT!r}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return data if binary else data.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def latest_version():
|
||||||
|
return fetch(LATEST_URL).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def find_resources_zip(version):
|
||||||
|
"""Scrape the version index for the resources zip, whose name carries a date."""
|
||||||
|
index = fetch(f"{BASE}/{version}/index.htm")
|
||||||
|
|
||||||
|
m = re.search(rf"ADIF_{version}_resources[_\d]*\.zip", index, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
sys.exit(f"no resources zip linked from {BASE}/{version}/index.htm - "
|
||||||
|
f"the page layout may have changed, check it by hand")
|
||||||
|
|
||||||
|
return f"{BASE}/{version}/{m.group(0)}"
|
||||||
|
|
||||||
|
|
||||||
|
def load_zip(path_or_url):
|
||||||
|
if os.path.exists(path_or_url):
|
||||||
|
return zipfile.ZipFile(path_or_url)
|
||||||
|
|
||||||
|
return zipfile.ZipFile(io.BytesIO(fetch(path_or_url, binary=True)))
|
||||||
|
|
||||||
|
|
||||||
|
def sniff_rows(zf, name):
|
||||||
|
"""Read a member as delimited text, guessing the delimiter."""
|
||||||
|
raw = zf.read(name).decode("utf-8-sig", errors="replace")
|
||||||
|
|
||||||
|
# ADIF's exports ship with CRLF. io.StringIO translates newlines by default,
|
||||||
|
# which leaves the \r attached to the last field of every row and makes csv
|
||||||
|
# raise "new-line character seen in unquoted field". Normalise first, then
|
||||||
|
# open with newline="" so csv does its own line splitting.
|
||||||
|
raw = raw.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
first_line = raw.split("\n", 1)[0]
|
||||||
|
|
||||||
|
# Prefer an explicit tab check over Sniffer. These files are tab-separated
|
||||||
|
# and their description columns contain commas, which Sniffer sometimes
|
||||||
|
# mistakes for the delimiter.
|
||||||
|
if "\t" in first_line:
|
||||||
|
dialect = csv.excel_tab
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dialect = csv.Sniffer().sniff(raw[:4096], delimiters="\t,;")
|
||||||
|
except csv.Error:
|
||||||
|
dialect = csv.excel_tab
|
||||||
|
|
||||||
|
reader = csv.reader(io.StringIO(raw, newline=""), dialect)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = [r for r in reader if any(cell.strip() for cell in r)]
|
||||||
|
except csv.Error as exc:
|
||||||
|
# Not a delimited file at all - a .adi test QSO file, say. Skip it
|
||||||
|
# rather than taking the whole run down.
|
||||||
|
sys.stderr.write(f"skipping {name}: {exc}\n")
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
return rows[0], rows[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def ident(name):
|
||||||
|
"""Turn an arbitrary header cell into a safe SQL column name."""
|
||||||
|
col = re.sub(r"[^0-9a-zA-Z]+", "_", name.strip().lower()).strip("_")
|
||||||
|
|
||||||
|
return col or "col"
|
||||||
|
|
||||||
|
|
||||||
|
def q(value):
|
||||||
|
if value is None or value == "":
|
||||||
|
return "NULL"
|
||||||
|
|
||||||
|
return "'" + str(value).replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
# Pure ADIF bookkeeping: identical on every row of every file, and recorded once
|
||||||
|
# in ref_sources instead. Everything else is kept even when constant, because a
|
||||||
|
# constant can be meaningful - adif_award.import_only is 'Import-only' on all 29
|
||||||
|
# rows, which says something real about those awards.
|
||||||
|
METADATA_COLUMNS = {"enumeration_name", "adif_version", "adif_status"}
|
||||||
|
|
||||||
|
|
||||||
|
def prune_columns(cols, rows, keep_all=False):
|
||||||
|
"""Drop ADIF bookkeeping and columns that are empty in this file.
|
||||||
|
|
||||||
|
Returns (kept_column_names, kept_rows, dropped_report).
|
||||||
|
"""
|
||||||
|
if keep_all:
|
||||||
|
return cols, rows, []
|
||||||
|
|
||||||
|
keep, dropped = [], []
|
||||||
|
|
||||||
|
for i, c in enumerate(cols):
|
||||||
|
values = {(r[i].strip() if i < len(r) and r[i] is not None else "") for r in rows}
|
||||||
|
|
||||||
|
# Compare on the normalised name: headers arrive as "Enumeration Name",
|
||||||
|
# "ADIF Version" and so on, and are only identifier-ised later.
|
||||||
|
if ident(c) in METADATA_COLUMNS:
|
||||||
|
sample = next(iter(values)) if len(values) == 1 else None
|
||||||
|
dropped.append((c, f"metadata{'=' + sample if sample else ''}"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if values <= {""}:
|
||||||
|
dropped.append((c, "empty"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
keep.append(i)
|
||||||
|
|
||||||
|
kept_cols = [cols[i] for i in keep]
|
||||||
|
kept_rows = [[(r[i] if i < len(r) else "") for i in keep] for r in rows]
|
||||||
|
|
||||||
|
return kept_cols, kept_rows, dropped
|
||||||
|
|
||||||
|
|
||||||
|
def emit_table(table, header, rows, out):
|
||||||
|
cols = []
|
||||||
|
seen = {}
|
||||||
|
for h in header:
|
||||||
|
c = ident(h)
|
||||||
|
if c in seen:
|
||||||
|
seen[c] += 1
|
||||||
|
c = f"{c}_{seen[c]}"
|
||||||
|
else:
|
||||||
|
seen[c] = 0
|
||||||
|
cols.append(c)
|
||||||
|
|
||||||
|
out.write(f"\nDROP TABLE IF EXISTS {table};\n")
|
||||||
|
out.write(f"CREATE TABLE {table} (\n")
|
||||||
|
out.write(",\n".join(f" {c} TEXT" for c in cols))
|
||||||
|
out.write("\n);\n\n")
|
||||||
|
|
||||||
|
out.write(f"INSERT INTO {table} ({', '.join(cols)}) VALUES\n")
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for r in rows:
|
||||||
|
# Pad or trim to the header width; ADIF exports occasionally have
|
||||||
|
# ragged trailing columns.
|
||||||
|
r = (list(r) + [""] * len(cols))[:len(cols)]
|
||||||
|
lines.append(" (" + ", ".join(q(cell.strip()) for cell in r) + ")")
|
||||||
|
|
||||||
|
out.write(",\n".join(lines) + ";\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--version", help="3-digit ADIF version (default: whatever is current)")
|
||||||
|
ap.add_argument("--zip", dest="zip_path", help="use a local zip instead of downloading")
|
||||||
|
ap.add_argument("--list", action="store_true", help="list zip contents and exit")
|
||||||
|
ap.add_argument("-o", "--output", help="write SQL here (default: stdout)")
|
||||||
|
ap.add_argument("--format", choices=FORMATS, default="tsv",
|
||||||
|
help="which export format to read (default: tsv). The zip also "
|
||||||
|
"contains json, xml, ods and xlsx copies, which are not "
|
||||||
|
"delimited text and are ignored")
|
||||||
|
ap.add_argument("--keep-all-columns", action="store_true",
|
||||||
|
help="keep ADIF bookkeeping columns (enumeration_name, "
|
||||||
|
"adif_version, adif_status) and columns that are empty "
|
||||||
|
"in this release")
|
||||||
|
ap.add_argument("--only", nargs="+", metavar="ENUM",
|
||||||
|
help="import just these enumerations by name, e.g. "
|
||||||
|
"--only mode band dxcc_entity_code")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.zip_path:
|
||||||
|
source = args.zip_path
|
||||||
|
version = args.version or "local"
|
||||||
|
else:
|
||||||
|
version = args.version or latest_version()
|
||||||
|
sys.stderr.write(f"ADIF version {version}\n")
|
||||||
|
source = find_resources_zip(version)
|
||||||
|
sys.stderr.write(f"resources: {source}\n")
|
||||||
|
|
||||||
|
zf = load_zip(source)
|
||||||
|
members = [n for n in zf.namelist() if not n.endswith("/")]
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
for n in sorted(members):
|
||||||
|
info = zf.getinfo(n)
|
||||||
|
print(f" {info.file_size:9d} {n}")
|
||||||
|
print(f"\n{len(members)} files")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pat = member_pattern(args.format)
|
||||||
|
only = {o.lower() for o in args.only} if args.only else None
|
||||||
|
|
||||||
|
picked = []
|
||||||
|
for n in members:
|
||||||
|
m = pat.search(n)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
|
||||||
|
enum_name = m.group("name").lower()
|
||||||
|
if only and enum_name not in only:
|
||||||
|
continue
|
||||||
|
|
||||||
|
picked.append((n, enum_name, table_for(enum_name)))
|
||||||
|
|
||||||
|
picked.sort(key=lambda x: x[2])
|
||||||
|
|
||||||
|
if not picked:
|
||||||
|
sys.exit(f"no exports/{args.format}/enumerations_*.{args.format} members found. "
|
||||||
|
f"Run with --list to see the zip layout.")
|
||||||
|
|
||||||
|
# One member per table, or a later file silently clobbers an earlier one.
|
||||||
|
by_table = {}
|
||||||
|
for n, enum_name, table in picked:
|
||||||
|
if table in by_table:
|
||||||
|
sys.exit(f"{table} claimed by both {by_table[table]} and {n} - "
|
||||||
|
f"add an entry to TABLE_ALIASES to disambiguate")
|
||||||
|
by_table[table] = n
|
||||||
|
|
||||||
|
out = open(args.output, "w", encoding="utf-8") if args.output else sys.stdout
|
||||||
|
try:
|
||||||
|
out.write("-- Generated by adif2sql.py. Do not edit by hand.\n")
|
||||||
|
out.write(f"-- ADIF version {version}, {args.format} exports, from {source}\n")
|
||||||
|
out.write("-- Columns are all TEXT: these are enumerations, and ADIF's own\n")
|
||||||
|
out.write("-- exports carry version-dependent extra columns. Cast at query time.\n")
|
||||||
|
|
||||||
|
for name, enum_name, table in picked:
|
||||||
|
header, rows = sniff_rows(zf, name)
|
||||||
|
if not header:
|
||||||
|
sys.stderr.write(f"skipping empty {name}\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
header, rows, dropped = prune_columns(header, rows, args.keep_all_columns)
|
||||||
|
|
||||||
|
note = f" (-{len(dropped)} cols)" if dropped else ""
|
||||||
|
sys.stderr.write(f"{enum_name:44} -> {table:32} {len(rows):5d} rows{note}\n")
|
||||||
|
|
||||||
|
emit_table(table, header, rows, out)
|
||||||
|
|
||||||
|
# Provenance, so audit.py stops complaining about undocumented tables.
|
||||||
|
out.write(
|
||||||
|
"\nINSERT OR REPLACE INTO ref_sources "
|
||||||
|
"(dataset, source_name, source_url, license, retrieved, notes) VALUES\n"
|
||||||
|
f" ({q(table)}, {q('ADIF ' + version + ' ' + enum_name)}, "
|
||||||
|
f"{q(source)}, 'ADIF specification, openly published', "
|
||||||
|
f"{q(RETRIEVED)}, "
|
||||||
|
f"{q('Generated by adif2sql.py. Dropped columns: ' + (', '.join(c for c, _ in dropped) or 'none'))});\n")
|
||||||
|
finally:
|
||||||
|
if args.output:
|
||||||
|
out.close()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+485
@@ -0,0 +1,485 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
audit.py -- consistency checks for the Hammy reference bundle.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 audit.py hammy-ref.sqlite # run every check
|
||||||
|
python3 audit.py hammy-ref.sqlite --quiet # only failures
|
||||||
|
python3 audit.py hammy-ref.sqlite --indexes # emit CREATE INDEX DDL
|
||||||
|
|
||||||
|
Exits non-zero if any check fails, so it drops straight into CI.
|
||||||
|
|
||||||
|
The checks fall into three groups:
|
||||||
|
|
||||||
|
structural SQLite's own integrity and declared foreign keys.
|
||||||
|
relational Joins that SHOULD hold but are not declared as foreign keys,
|
||||||
|
mostly because the ADIF tables are generated with TEXT columns.
|
||||||
|
Orphans here mean two datasets disagree.
|
||||||
|
domain Amateur-radio specific invariants. Band edges that cross, gaps in
|
||||||
|
the beacon cycle, prefixes that resolve to nothing. These are the
|
||||||
|
ones that catch a bad hand-entered row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
FAILURES = []
|
||||||
|
WARNINGS = []
|
||||||
|
|
||||||
|
|
||||||
|
def fail(check, detail):
|
||||||
|
FAILURES.append((check, detail))
|
||||||
|
|
||||||
|
|
||||||
|
def warn(check, detail):
|
||||||
|
WARNINGS.append((check, detail))
|
||||||
|
|
||||||
|
|
||||||
|
def tables(db):
|
||||||
|
return [r[0] for r in db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||||
|
"AND name NOT LIKE 'sqlite_%' ORDER BY name")]
|
||||||
|
|
||||||
|
|
||||||
|
def columns(db, table):
|
||||||
|
return [r[1] for r in db.execute(f"PRAGMA table_info({table})")]
|
||||||
|
|
||||||
|
|
||||||
|
def indexed_columns(db, table):
|
||||||
|
"""Columns usable as the LEADING column of some index or the primary key.
|
||||||
|
|
||||||
|
In a composite key (prefix, entity_id) only 'prefix' is reachable; a query
|
||||||
|
filtering on entity_id alone still scans. PRAGMA table_info's pk field is
|
||||||
|
the 1-based position within the key, so pk == 1 is the leading column.
|
||||||
|
"""
|
||||||
|
covered = set()
|
||||||
|
|
||||||
|
for row in db.execute(f"PRAGMA table_info({table})"):
|
||||||
|
if row[5] == 1:
|
||||||
|
covered.add(row[1])
|
||||||
|
|
||||||
|
for idx in db.execute(f"PRAGMA index_list({table})"):
|
||||||
|
info = list(db.execute(f"PRAGMA index_info({idx[1]})"))
|
||||||
|
if info:
|
||||||
|
covered.add(info[0][2])
|
||||||
|
|
||||||
|
return covered
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Structural
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_structural(db, verbose):
|
||||||
|
result = db.execute("PRAGMA integrity_check").fetchone()[0]
|
||||||
|
if result != "ok":
|
||||||
|
fail("integrity_check", result)
|
||||||
|
elif verbose:
|
||||||
|
print(" integrity_check ok")
|
||||||
|
|
||||||
|
violations = db.execute("PRAGMA foreign_key_check").fetchall()
|
||||||
|
if violations:
|
||||||
|
for v in violations[:10]:
|
||||||
|
fail("foreign_key_check", f"{v[0]} rowid {v[1]} -> {v[2]}")
|
||||||
|
elif verbose:
|
||||||
|
print(" foreign_key_check ok")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Relational: undeclared joins that should still hold
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# (child table, child column, parent table, parent column, description)
|
||||||
|
RELATIONS = [
|
||||||
|
("dxcc_prefixes", "entity_id", "dxcc_entities", "id",
|
||||||
|
"every prefix resolves to an entity"),
|
||||||
|
("band_segments", "band_id", "bands", "id",
|
||||||
|
"every segment belongs to a band"),
|
||||||
|
("band_segments", "class_id", "license_classes", "id",
|
||||||
|
"every segment's licence class exists"),
|
||||||
|
("ncdxf_beacons", "dxcc_id", "dxcc_entities", "id",
|
||||||
|
"beacon entities resolve"),
|
||||||
|
("ncdxf_frequencies", "band_id", "bands", "id",
|
||||||
|
"beacon frequencies map to a band"),
|
||||||
|
# ADIF tables are all TEXT, so these need a CAST to join against integers.
|
||||||
|
("adif_subdivisions", "dxcc_entity_code", "adif_dxcc", "entity_code",
|
||||||
|
"subdivisions point at a real ADIF entity"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def check_relations(db, verbose):
|
||||||
|
present = set(tables(db))
|
||||||
|
|
||||||
|
for child, ccol, parent, pcol, desc in RELATIONS:
|
||||||
|
if child not in present or parent not in present:
|
||||||
|
continue
|
||||||
|
if ccol not in columns(db, child) or pcol not in columns(db, parent):
|
||||||
|
warn(f"{child}.{ccol}", f"column missing, skipped ({desc})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# TRIM both sides: the ADIF exports pad some cells.
|
||||||
|
q = (f"SELECT COUNT(*) FROM {child} c "
|
||||||
|
f"WHERE c.{ccol} IS NOT NULL AND TRIM(c.{ccol}) <> '' "
|
||||||
|
f"AND NOT EXISTS (SELECT 1 FROM {parent} p "
|
||||||
|
f" WHERE TRIM(p.{pcol}) = TRIM(c.{ccol}))")
|
||||||
|
orphans = db.execute(q).fetchone()[0]
|
||||||
|
|
||||||
|
if orphans:
|
||||||
|
sample = db.execute(
|
||||||
|
f"SELECT DISTINCT c.{ccol} FROM {child} c "
|
||||||
|
f"WHERE NOT EXISTS (SELECT 1 FROM {parent} p "
|
||||||
|
f"WHERE TRIM(p.{pcol}) = TRIM(c.{ccol})) "
|
||||||
|
f"AND TRIM(c.{ccol}) <> '' LIMIT 5").fetchall()
|
||||||
|
vals = ", ".join(repr(s[0]) for s in sample)
|
||||||
|
fail(f"{child}.{ccol} -> {parent}.{pcol}",
|
||||||
|
f"{orphans} orphan rows ({desc}); e.g. {vals}")
|
||||||
|
elif verbose:
|
||||||
|
print(f" {child}.{ccol} -> {parent}.{pcol}".ljust(66) + "ok")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain invariants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_domain(db, verbose):
|
||||||
|
present = set(tables(db))
|
||||||
|
|
||||||
|
def q1(sql, *args):
|
||||||
|
return db.execute(sql, args).fetchone()[0]
|
||||||
|
|
||||||
|
if "band_segments" in present:
|
||||||
|
n = q1("SELECT COUNT(*) FROM band_segments WHERE low_hz >= high_hz")
|
||||||
|
if n:
|
||||||
|
fail("band_segments", f"{n} rows where low_hz >= high_hz")
|
||||||
|
elif verbose:
|
||||||
|
print(" band_segments edges ordered".ljust(66) + "ok")
|
||||||
|
|
||||||
|
# A segment outside its own band's extent is almost always a typo.
|
||||||
|
rows = db.execute("""
|
||||||
|
SELECT b.name, s.low_hz, s.high_hz, b.edge_low_hz, b.edge_high_hz
|
||||||
|
FROM band_segments s JOIN bands b ON b.id = s.band_id
|
||||||
|
WHERE s.low_hz < b.edge_low_hz OR s.high_hz > b.edge_high_hz""").fetchall()
|
||||||
|
if rows:
|
||||||
|
for r in rows[:5]:
|
||||||
|
fail("band_segments", f"{r[0]} segment {r[1]}-{r[2]} outside band {r[3]}-{r[4]}")
|
||||||
|
elif verbose:
|
||||||
|
print(" band_segments within band edges".ljust(66) + "ok")
|
||||||
|
|
||||||
|
# Two segments for the same country+region+class+mode should not overlap.
|
||||||
|
# iaru_region must be part of the key: the Region 1 and Region 3
|
||||||
|
# allocation rows share country='' and legitimately cover the same
|
||||||
|
# frequencies.
|
||||||
|
rows = db.execute("""
|
||||||
|
SELECT a.country, a.iaru_region, a.class_id, a.modes,
|
||||||
|
a.low_hz, a.high_hz, b.low_hz, b.high_hz
|
||||||
|
FROM band_segments a JOIN band_segments b
|
||||||
|
ON a.id < b.id AND a.country = b.country
|
||||||
|
AND IFNULL(a.iaru_region,-1) = IFNULL(b.iaru_region,-1)
|
||||||
|
AND IFNULL(a.class_id,-1) = IFNULL(b.class_id,-1)
|
||||||
|
AND a.modes = b.modes
|
||||||
|
AND a.low_hz < b.high_hz AND b.low_hz < a.high_hz""").fetchall()
|
||||||
|
if rows:
|
||||||
|
for r in rows[:5]:
|
||||||
|
warn("band_segments", f"{r[0] or 'IARU R' + str(r[1])} class {r[2]} {r[3]}: "
|
||||||
|
f"{r[4]}-{r[5]} overlaps {r[6]}-{r[7]}")
|
||||||
|
elif verbose:
|
||||||
|
print(" band_segments no overlaps".ljust(66) + "ok")
|
||||||
|
|
||||||
|
# Privilege nesting. In most licensing regimes a higher class holds a
|
||||||
|
# superset of a lower one, so a segment that a junior class has and a
|
||||||
|
# senior class does not is nearly always a missing row rather than a
|
||||||
|
# real rule. This is exactly the shape of bug that reads as plausible
|
||||||
|
# output - "Extra: not permitted on 2m" looks like data, not an error.
|
||||||
|
#
|
||||||
|
# A warning, not a failure: the superset assumption is true of the US
|
||||||
|
# and most countries, but it is an assumption, and a contributed band
|
||||||
|
# plan could legitimately break it.
|
||||||
|
rows = db.execute("""
|
||||||
|
SELECT DISTINCT hi.code, lo.code, b.name, s.low_hz, s.high_hz
|
||||||
|
FROM band_segments s
|
||||||
|
JOIN license_classes lo ON lo.id = s.class_id
|
||||||
|
JOIN license_classes hi ON hi.country = lo.country AND hi.rank > lo.rank
|
||||||
|
JOIN bands b ON b.id = s.band_id
|
||||||
|
WHERE s.country = lo.country
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM band_segments t
|
||||||
|
WHERE t.country = s.country
|
||||||
|
AND t.class_id = hi.id
|
||||||
|
AND t.low_hz <= s.low_hz
|
||||||
|
AND s.high_hz <= t.high_hz)
|
||||||
|
ORDER BY b.name, hi.rank DESC""").fetchall()
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
for r in rows[:8]:
|
||||||
|
warn("band_segments",
|
||||||
|
f"{r[0]} lacks {r[2]} {r[3]/1e6:.3f}-{r[4]/1e6:.3f} MHz "
|
||||||
|
f"that {r[1]} holds - missing row?")
|
||||||
|
if len(rows) > 8:
|
||||||
|
warn("band_segments", f"...and {len(rows) - 8} more nesting gaps")
|
||||||
|
elif verbose:
|
||||||
|
print(" band_segments privileges nest by rank".ljust(66) + "ok")
|
||||||
|
|
||||||
|
if "ncdxf_beacons" in present:
|
||||||
|
slots = [r[0] for r in db.execute("SELECT slot_index FROM ncdxf_beacons ORDER BY slot_index")]
|
||||||
|
if slots != list(range(18)):
|
||||||
|
fail("ncdxf_beacons", f"expected slots 0..17, got {len(slots)}: {slots}")
|
||||||
|
elif verbose:
|
||||||
|
print(" ncdxf_beacons slots 0..17 complete".ljust(66) + "ok")
|
||||||
|
|
||||||
|
if "dxcc_prefixes" in present:
|
||||||
|
# Longest-prefix matching breaks if a prefix is empty or has whitespace.
|
||||||
|
n = q1("SELECT COUNT(*) FROM dxcc_prefixes WHERE prefix IS NULL OR TRIM(prefix) <> prefix OR prefix = ''")
|
||||||
|
if n:
|
||||||
|
fail("dxcc_prefixes", f"{n} prefixes empty or with surrounding whitespace")
|
||||||
|
elif verbose:
|
||||||
|
print(" dxcc_prefixes clean".ljust(66) + "ok")
|
||||||
|
|
||||||
|
# Zone overrides that merely restate the entity default are noise.
|
||||||
|
n = q1("""SELECT COUNT(*) FROM dxcc_prefixes p JOIN dxcc_entities e ON e.id = p.entity_id
|
||||||
|
WHERE p.cq_zone = e.cq_zone AND p.itu_zone = e.itu_zone""")
|
||||||
|
if n:
|
||||||
|
warn("dxcc_prefixes", f"{n} rows whose zone override equals the entity default")
|
||||||
|
|
||||||
|
if "morse" in present:
|
||||||
|
n = q1("SELECT COUNT(*) FROM (SELECT code FROM morse GROUP BY code HAVING COUNT(*) > 1)")
|
||||||
|
if n:
|
||||||
|
fail("morse", f"{n} duplicate codes - decoding would be ambiguous")
|
||||||
|
elif verbose:
|
||||||
|
print(" morse codes unique".ljust(66) + "ok")
|
||||||
|
|
||||||
|
if "ref_sources" in present:
|
||||||
|
# Every populated table should say where it came from.
|
||||||
|
documented = {r[0] for r in db.execute("SELECT dataset FROM ref_sources")}
|
||||||
|
undocumented = []
|
||||||
|
for t in tables(db):
|
||||||
|
if t.startswith(("ref_", "sqlite_")):
|
||||||
|
continue
|
||||||
|
if q1(f"SELECT COUNT(*) FROM {t}") == 0:
|
||||||
|
continue
|
||||||
|
if t not in documented and not any(d in t or t in d for d in documented):
|
||||||
|
undocumented.append(t)
|
||||||
|
if undocumented:
|
||||||
|
warn("ref_sources", f"no provenance row for: {', '.join(undocumented)}")
|
||||||
|
elif verbose:
|
||||||
|
print(" ref_sources covers every table".ljust(66) + "ok")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dead weight
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_dead_columns(db, verbose, group=True):
|
||||||
|
"""Constant or empty columns.
|
||||||
|
|
||||||
|
A constant column is not automatically a bug: adif_award.import_only is
|
||||||
|
'Import-only' on every row because every award in that enumeration is, and
|
||||||
|
adif_subdivisions_secondary.dxcc_entity_code is '6' because Alaska is the
|
||||||
|
only entity with secondary subdivisions. Empty columns are always dead
|
||||||
|
weight. Both are reported, but repeated findings across many tables collapse
|
||||||
|
into one line so the signal is not buried.
|
||||||
|
"""
|
||||||
|
empties = []
|
||||||
|
constants = []
|
||||||
|
|
||||||
|
for t in tables(db):
|
||||||
|
total = db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
|
||||||
|
if total < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for c in columns(db, t):
|
||||||
|
distinct = db.execute(
|
||||||
|
f'SELECT COUNT(DISTINCT IFNULL("{c}", char(0))) FROM "{t}"').fetchone()[0]
|
||||||
|
|
||||||
|
if distinct != 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
val = db.execute(f'SELECT "{c}" FROM "{t}" LIMIT 1').fetchone()[0]
|
||||||
|
|
||||||
|
if val is None or str(val).strip() == "":
|
||||||
|
empties.append((t, c, total))
|
||||||
|
else:
|
||||||
|
constants.append((t, c, val, total))
|
||||||
|
|
||||||
|
if group:
|
||||||
|
# Collapse by column name: the same finding across 20 ADIF tables is one
|
||||||
|
# fact about the export format, not 20 problems.
|
||||||
|
by_col = {}
|
||||||
|
for t, c, total in empties:
|
||||||
|
by_col.setdefault(c, []).append(t)
|
||||||
|
for c, ts in sorted(by_col.items()):
|
||||||
|
if len(ts) > 2:
|
||||||
|
warn(f"*.{c}", f"entirely empty in {len(ts)} tables - drop it "
|
||||||
|
f"({', '.join(ts[:3])}, ...)")
|
||||||
|
else:
|
||||||
|
for t in ts:
|
||||||
|
warn(f"{t}.{c}", "entirely empty - drop it")
|
||||||
|
|
||||||
|
by_col = {}
|
||||||
|
for t, c, val, total in constants:
|
||||||
|
by_col.setdefault((c, str(val)), []).append(t)
|
||||||
|
for (c, val), ts in sorted(by_col.items()):
|
||||||
|
if len(ts) > 2:
|
||||||
|
warn(f"*.{c}", f"constant {val!r} in {len(ts)} tables - redundant "
|
||||||
|
f"({', '.join(ts[:3])}, ...)")
|
||||||
|
else:
|
||||||
|
for t in ts:
|
||||||
|
warn(f"{t}.{c}", f"constant {val!r} - check it is meaningful")
|
||||||
|
else:
|
||||||
|
for t, c, total in empties:
|
||||||
|
warn(f"{t}.{c}", f"entirely empty across {total} rows - drop it")
|
||||||
|
for t, c, val, total in constants:
|
||||||
|
warn(f"{t}.{c}", f"constant {val!r} across {total} rows - redundant")
|
||||||
|
|
||||||
|
|
||||||
|
def check_duplicates(db, verbose):
|
||||||
|
"""Exact duplicate rows, which in a reference table are always a mistake."""
|
||||||
|
for t in tables(db):
|
||||||
|
cols = columns(db, t)
|
||||||
|
if not cols:
|
||||||
|
continue
|
||||||
|
|
||||||
|
collist = ", ".join(f'"{c}"' for c in cols)
|
||||||
|
n = db.execute(
|
||||||
|
f"SELECT COUNT(*) FROM (SELECT {collist}, COUNT(*) AS n "
|
||||||
|
f'FROM "{t}" GROUP BY {collist} HAVING n > 1)').fetchone()[0]
|
||||||
|
|
||||||
|
if n:
|
||||||
|
warn(t, f"{n} groups of exactly duplicated rows")
|
||||||
|
elif verbose:
|
||||||
|
print(f" {t} no duplicate rows".ljust(66) + "ok")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Index generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Columns worth indexing, by suffix or exact name.
|
||||||
|
#
|
||||||
|
# An index only helps a query that filters with equality or a range on its
|
||||||
|
# leading column. It does NOT help "? GLOB prefix || '*'", because the indexed
|
||||||
|
# column is on the wrong side of the comparison - SQLite still scans every row
|
||||||
|
# and evaluates the GLOB. Worse, an index can make such a query slower by
|
||||||
|
# tempting the planner into a nested loop.
|
||||||
|
#
|
||||||
|
# The fix for callsign lookup is on the query side, not here: generate the
|
||||||
|
# candidate prefixes from the callsign and do equality seeks, longest first.
|
||||||
|
# See docs in the repo. That turns a 7000-row scan into a handful of index
|
||||||
|
# seeks, measured at roughly 40x on the current bundle.
|
||||||
|
INDEX_HINTS = ("code", "_code", "_id", "name", "prefix", "callsign", "band",
|
||||||
|
"mode", "abbr", "symbol", "letter", "character", "grid",
|
||||||
|
"country", "continent", "cq_zone", "itu_zone", "dataset")
|
||||||
|
|
||||||
|
|
||||||
|
def emit_indexes(db, out):
|
||||||
|
out.write("-- Generated by audit.py --indexes. Runs last in the build.\n")
|
||||||
|
out.write("-- The bundle is read-only, so indexes cost file size and nothing else.\n\n")
|
||||||
|
|
||||||
|
# Indexes the heuristic cannot infer but the bot's hot paths need.
|
||||||
|
ESSENTIAL = [
|
||||||
|
("dxcc_prefixes", "prefix",
|
||||||
|
"candidate-prefix equality lookup; leading PK column, but stated "
|
||||||
|
"explicitly because everything depends on it"),
|
||||||
|
("dxcc_prefixes", "entity_id", "joins back to dxcc_entities"),
|
||||||
|
]
|
||||||
|
|
||||||
|
made = 0
|
||||||
|
present = set(tables(db))
|
||||||
|
for t, c, why in ESSENTIAL:
|
||||||
|
if t in present and c in columns(db, t):
|
||||||
|
out.write(f"-- {why}\n")
|
||||||
|
out.write(f'CREATE INDEX IF NOT EXISTS idx_{t}_{c} ON "{t}" ("{c}");\n')
|
||||||
|
made += 1
|
||||||
|
out.write("\n")
|
||||||
|
|
||||||
|
for t in tables(db):
|
||||||
|
if t.startswith("ref_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
total = db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
|
||||||
|
if total < 50: # a scan of 50 rows is free
|
||||||
|
continue
|
||||||
|
|
||||||
|
covered = indexed_columns(db, t)
|
||||||
|
|
||||||
|
for c in columns(db, t):
|
||||||
|
if c in covered or (t, c) in {(a, b) for a, b, _ in ESSENTIAL}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lc = c.lower()
|
||||||
|
if not (lc in INDEX_HINTS or any(lc.endswith(h) for h in INDEX_HINTS)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# A column with almost no distinct values is not worth an index.
|
||||||
|
distinct = db.execute(f'SELECT COUNT(DISTINCT "{c}") FROM "{t}"').fetchone()[0]
|
||||||
|
if distinct < 2 or distinct < total / 100:
|
||||||
|
continue
|
||||||
|
|
||||||
|
out.write(f'CREATE INDEX IF NOT EXISTS idx_{t}_{lc} ON "{t}" ("{c}");\n')
|
||||||
|
made += 1
|
||||||
|
|
||||||
|
out.write("\n")
|
||||||
|
|
||||||
|
out.write("ANALYZE;\n")
|
||||||
|
sys.stderr.write(f"{made} indexes\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Audit the Hammy reference bundle")
|
||||||
|
ap.add_argument("db", help="path to the bundle")
|
||||||
|
ap.add_argument("--quiet", action="store_true", help="only report problems")
|
||||||
|
ap.add_argument("--ungrouped", action="store_true",
|
||||||
|
help="report every constant/empty column separately instead of "
|
||||||
|
"collapsing repeated findings")
|
||||||
|
ap.add_argument("--indexes", action="store_true",
|
||||||
|
help="emit CREATE INDEX DDL to stdout instead of auditing")
|
||||||
|
ap.add_argument("-o", "--output", help="write index DDL here")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
db = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
|
||||||
|
db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
if args.indexes:
|
||||||
|
out = open(args.output, "w") if args.output else sys.stdout
|
||||||
|
try:
|
||||||
|
emit_indexes(db, out)
|
||||||
|
finally:
|
||||||
|
if args.output:
|
||||||
|
out.close()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
verbose = not args.quiet
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print("structural")
|
||||||
|
check_structural(db, verbose)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print("\nrelational")
|
||||||
|
check_relations(db, verbose)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print("\ndomain")
|
||||||
|
check_domain(db, verbose)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print("\ndead weight and duplicates")
|
||||||
|
check_dead_columns(db, verbose, group=not args.ungrouped)
|
||||||
|
check_duplicates(db, False)
|
||||||
|
|
||||||
|
print()
|
||||||
|
for check, detail in WARNINGS:
|
||||||
|
print(f"WARN {check}: {detail}")
|
||||||
|
for check, detail in FAILURES:
|
||||||
|
print(f"FAIL {check}: {detail}")
|
||||||
|
|
||||||
|
print(f"\n{len(FAILURES)} failures, {len(WARNINGS)} warnings")
|
||||||
|
|
||||||
|
return 1 if FAILURES else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+421
@@ -0,0 +1,421 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
cty2sql.py -- convert AD1C cty.dat (ham radio DXCC country file) into SQL
|
||||||
|
INSERT statements for the dxcc_entities / dxcc_prefixes tables.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 cty2sql.py cty.dat > dxcc.sql
|
||||||
|
python3 cty2sql.py cty.dat -o dxcc.sql --schema --transaction
|
||||||
|
|
||||||
|
Notes on cty.dat that this script deals with for you:
|
||||||
|
|
||||||
|
* cty.dat has NO DXCC entity numbers. The ADIF entity code (1 = Canada,
|
||||||
|
291 = United States, ...) is supplied by the DXCC_IDS table below, keyed
|
||||||
|
on the record's primary prefix. Override or extend it with --dxcc-map
|
||||||
|
(a JSON file of {"primary_prefix": entity_id}).
|
||||||
|
* cty.dat longitudes are POSITIVE WEST. This script flips them to the
|
||||||
|
conventional positive-east form by default (Ottawa -> -75.0). Use
|
||||||
|
--longitude as-is to keep the raw cty.dat sign.
|
||||||
|
* cty.dat UTC offsets are also positive-west (Canada 5.0, Japan -9.0) and
|
||||||
|
are emitted unchanged by default, matching the target schema. Use
|
||||||
|
--utc-offset standard to flip them into real UTC offsets (Japan +9.0).
|
||||||
|
* Entities whose primary prefix starts with "*" are WAE/CQ-only entities
|
||||||
|
(Sicily, Shetland Is., European Turkey, ...). They are not DXCC entities
|
||||||
|
and have no entity code, so they are skipped unless --include-wae is
|
||||||
|
given (which requires you to supply ids for them via --dxcc-map).
|
||||||
|
* Prefix modifiers are stripped: (cq) [itu] <lat/lon> {cont} ~offset~.
|
||||||
|
A prefix written "=CALL" is a full callsign match and is emitted with
|
||||||
|
exact = 1.
|
||||||
|
|
||||||
|
No third-party dependencies. Python 3.8+.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# cty.dat primary prefix -> ADIF DXCC entity code (ADIF 3.1.6 enumeration).
|
||||||
|
# Deleted entities are not included. Regenerate/extend with --dxcc-map.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
DXCC_IDS = {
|
||||||
|
"VE": 1, "YA": 3, "3B6": 4, "OH0": 5, "KL": 6, "ZA": 7, "KH8": 9, "FT/z": 10, "VU4": 11,
|
||||||
|
"VP2E": 12, "CE9": 13, "EK": 14, "UA9": 15, "ZL9": 16, "YV0": 17, "4J": 18, "KH1": 20,
|
||||||
|
"EA6": 21, "T8": 22, "3Y/b": 24, "EU": 27, "EA8": 29, "T31": 31, "EA9": 32, "VQ9": 33,
|
||||||
|
"ZL7": 34, "VK9X": 35, "FO/c": 36, "TI9": 37, "VK9C": 38, "SV9": 40, "FT/w": 41, "KP5": 43,
|
||||||
|
"SV5": 45, "9M6": 46, "CE0Y": 47, "T32": 48, "3C": 49, "XE": 50, "E3": 51, "ES": 52,
|
||||||
|
"ET": 53, "UA": 54, "PY0F": 56, "C6": 60, "R1FJ": 61, "8P": 62, "FY": 63, "VP9": 64,
|
||||||
|
"VP2V": 65, "V3": 66, "ZF": 69, "CM": 70, "HC8": 71, "HI": 72, "YS": 74, "4L": 75,
|
||||||
|
"TG": 76, "J3": 77, "HH": 78, "FG": 79, "HR": 80, "6Y": 82, "FM": 84, "YN": 86, "HP": 88,
|
||||||
|
"VP5": 89, "9Y": 90, "P4": 91, "V2": 94, "J7": 95, "VP2M": 96, "J6": 97, "J8": 98,
|
||||||
|
"FT/g": 99, "LU": 100, "KH2": 103, "CP": 104, "KG4": 105, "GU": 106, "3X": 107, "PY": 108,
|
||||||
|
"J5": 109, "KH6": 110, "VK0H": 111, "CE": 112, "GD": 114, "HK": 116, "4U1I": 117,
|
||||||
|
"JX": 118, "HC": 120, "GJ": 122, "KH3": 123, "FT/j": 124, "CE0Z": 125, "UA2": 126,
|
||||||
|
"8R": 129, "UN": 130, "FT/x": 131, "ZP": 132, "ZL8": 133, "EX": 135, "OA": 136, "HL": 137,
|
||||||
|
"KH7K": 138, "PZ": 140, "VP8": 141, "VU7": 142, "XW": 143, "CX": 144, "YL": 145, "LY": 146,
|
||||||
|
"VK9L": 147, "YV": 148, "CU": 149, "VK": 150, "XX9": 152, "VK0M": 153, "C2": 157,
|
||||||
|
"YJ": 158, "8Q": 159, "A3": 160, "HK0/m": 161, "FK": 162, "P2": 163, "3B8": 165,
|
||||||
|
"KH0": 166, "OJ0": 167, "V7": 168, "FH": 169, "ZL": 170, "VK9M": 171, "VP6": 172,
|
||||||
|
"V6": 173, "KH4": 174, "FO": 175, "3D2": 176, "JD/m": 177, "ER": 179, "SV/a": 180,
|
||||||
|
"C9": 181, "KP1": 182, "H4": 185, "5U": 187, "E6": 188, "VK9N": 189, "5W": 190,
|
||||||
|
"E5/n": 191, "JD/o": 192, "3C0": 195, "KH5": 197, "3Y/p": 199, "ZS8": 201, "KP4": 202,
|
||||||
|
"C3": 203, "XF4": 204, "ZD8": 205, "OE": 206, "3B9": 207, "ON": 209, "CY0": 211, "LZ": 212,
|
||||||
|
"FS": 213, "TK": 214, "5B": 215, "HK0/a": 216, "CE0X": 217, "S9": 219, "OZ": 221,
|
||||||
|
"OY": 222, "G": 223, "OH": 224, "IS": 225, "F": 227, "DL": 230, "T5": 232, "ZB": 233,
|
||||||
|
"E5/s": 234, "VP8/g": 235, "SV": 236, "OX": 237, "VP8/o": 238, "HA": 239, "VP8/s": 240,
|
||||||
|
"VP8/h": 241, "TF": 242, "EI": 245, "1A": 246, "1S": 247, "I": 248, "V4": 249, "ZD7": 250,
|
||||||
|
"HB0": 251, "CY9": 252, "PY0S": 253, "LX": 254, "CT3": 256, "9H": 257, "JW": 259,
|
||||||
|
"3A": 260, "EY": 262, "PA": 263, "GI": 265, "LA": 266, "SP": 269, "ZK3": 270, "CT": 272,
|
||||||
|
"PY0T": 273, "ZD9": 274, "YO": 275, "FT/t": 276, "FP": 277, "T7": 278, "GM": 279,
|
||||||
|
"EZ": 280, "EA": 281, "T2": 282, "ZC4": 283, "SM": 284, "KP2": 285, "5X": 286, "HB": 287,
|
||||||
|
"UR": 288, "4U1U": 289, "K": 291, "UK": 292, "3W": 293, "GW": 294, "HV": 295, "YU": 296,
|
||||||
|
"KH9": 297, "FW": 298, "9M2": 299, "T30": 301, "S0": 302, "VK9W": 303, "A9": 304,
|
||||||
|
"S2": 305, "A5": 306, "TI": 308, "XZ": 309, "XU": 312, "4S": 315, "BY": 318, "VR": 321,
|
||||||
|
"VU": 324, "YB": 327, "EP": 330, "YI": 333, "4X": 336, "JA": 339, "JY": 342, "P5": 344,
|
||||||
|
"V8": 345, "9K": 348, "OD": 354, "JT": 363, "9N": 369, "A4": 370, "AP": 372, "DU": 375,
|
||||||
|
"A7": 376, "HZ": 378, "S7": 379, "9V": 381, "J2": 382, "YK": 384, "BV": 386, "HS": 387,
|
||||||
|
"TA": 390, "A6": 391, "7X": 400, "D2": 401, "A2": 402, "9U": 404, "TJ": 406, "TL": 408,
|
||||||
|
"D4": 409, "TT": 410, "D6": 411, "TN": 412, "9Q": 414, "TY": 416, "TR": 420, "C5": 422,
|
||||||
|
"9G": 424, "TU": 428, "5Z": 430, "7P": 432, "EL": 434, "5A": 436, "5R": 438, "7Q": 440,
|
||||||
|
"TZ": 442, "5T": 444, "CN": 446, "5N": 450, "Z2": 452, "FR": 453, "9X": 454, "6W": 456,
|
||||||
|
"9L": 458, "3D2/r": 460, "ZS": 462, "V5": 464, "ST": 466, "3DA": 468, "5H": 470, "3V": 474,
|
||||||
|
"SU": 478, "XT": 480, "9J": 482, "5V": 483, "3D2/c": 489, "T33": 490, "7O": 492, "9A": 497,
|
||||||
|
"S5": 499, "E7": 501, "Z3": 502, "OK": 503, "OM": 504, "BV9P": 505, "BS7": 506, "H40": 507,
|
||||||
|
"FO/a": 508, "FO/m": 509, "E4": 510, "4W": 511, "FK/c": 512, "VP6/d": 513, "4O": 514,
|
||||||
|
"KH8/s": 515, "FJ": 516, "PJ2": 517, "PJ7": 518, "PJ5": 519, "PJ4": 520, "Z8": 521,
|
||||||
|
"Z6": 522
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Parsing
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Modifiers that may be attached to a prefix inside a cty.dat prefix list.
|
||||||
|
# These are CAPTURED rather than discarded: about 75% of the prefix tokens in a
|
||||||
|
# current cty.dat carry a zone override, and they exist precisely because the
|
||||||
|
# record default is wrong for that prefix. VK4[55] is Queensland at ITU 55, not
|
||||||
|
# Australia's default 59; drop the modifier and every Queensland call gets the
|
||||||
|
# wrong zone.
|
||||||
|
MOD_CQ = re.compile(r"\(\s*(\d+)\s*\)")
|
||||||
|
MOD_ITU = re.compile(r"\[\s*(\d+)\s*\]")
|
||||||
|
MOD_LATLON = re.compile(r"<\s*([-\d.]+)\s*/\s*([-\d.]+)\s*>")
|
||||||
|
MOD_CONT = re.compile(r"\{([^}]*)\}")
|
||||||
|
MOD_OFFSET = re.compile(r"~([^~]*)~")
|
||||||
|
|
||||||
|
MODIFIER_RE = re.compile(r"""
|
||||||
|
\(\s*\d+\s*\)
|
||||||
|
| \[\s*\d+\s*\]
|
||||||
|
| <[^>]*>
|
||||||
|
| \{[^}]*\}
|
||||||
|
| ~[^~]*~
|
||||||
|
""", re.VERBOSE)
|
||||||
|
|
||||||
|
|
||||||
|
def split_modifiers(token):
|
||||||
|
"""Return (bare_prefix, overrides dict) for one prefix-list token."""
|
||||||
|
ov = {"cq_zone": None, "itu_zone": None, "latitude": None,
|
||||||
|
"longitude": None, "continent": None, "utc_offset": None}
|
||||||
|
|
||||||
|
m = MOD_CQ.search(token)
|
||||||
|
if m:
|
||||||
|
ov["cq_zone"] = int(m.group(1))
|
||||||
|
|
||||||
|
m = MOD_ITU.search(token)
|
||||||
|
if m:
|
||||||
|
ov["itu_zone"] = int(m.group(1))
|
||||||
|
|
||||||
|
m = MOD_LATLON.search(token)
|
||||||
|
if m:
|
||||||
|
ov["latitude"] = float(m.group(1))
|
||||||
|
ov["longitude"] = float(m.group(2)) # still positive-west here
|
||||||
|
|
||||||
|
m = MOD_CONT.search(token)
|
||||||
|
if m:
|
||||||
|
ov["continent"] = m.group(1).strip().upper() or None
|
||||||
|
|
||||||
|
m = MOD_OFFSET.search(token)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
ov["utc_offset"] = float(m.group(1))
|
||||||
|
except ValueError:
|
||||||
|
ov["utc_offset"] = None
|
||||||
|
|
||||||
|
return MODIFIER_RE.sub("", token).strip(), ov
|
||||||
|
|
||||||
|
|
||||||
|
class Entity:
|
||||||
|
__slots__ = ("name", "cq_zone", "itu_zone", "continent", "latitude",
|
||||||
|
"longitude", "utc_offset", "primary", "wae", "prefixes",
|
||||||
|
"entity_id", "line_no")
|
||||||
|
|
||||||
|
def __init__(self, **kw):
|
||||||
|
for k in self.__slots__:
|
||||||
|
setattr(self, k, kw.get(k))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cty(text):
|
||||||
|
"""Yield Entity objects from the contents of a cty.dat file."""
|
||||||
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
# Records are terminated by ';'. Track line numbers for error messages.
|
||||||
|
pos = 0
|
||||||
|
line_no = 1
|
||||||
|
for chunk in text.split(";"):
|
||||||
|
start_line = line_no + chunk[:len(chunk) - len(chunk.lstrip("\n"))].count("\n")
|
||||||
|
line_no += chunk.count("\n")
|
||||||
|
record = chunk.strip()
|
||||||
|
if not record:
|
||||||
|
continue
|
||||||
|
|
||||||
|
head, _, body = record.partition("\n")
|
||||||
|
fields = [f.strip() for f in head.split(":")]
|
||||||
|
if len(fields) < 8:
|
||||||
|
raise ValueError(
|
||||||
|
"line %d: expected 8 colon-separated header fields, got %d: %r"
|
||||||
|
% (start_line, len(fields), head))
|
||||||
|
|
||||||
|
primary = fields[7]
|
||||||
|
wae = primary.startswith("*")
|
||||||
|
|
||||||
|
ent = Entity(
|
||||||
|
name=fields[0],
|
||||||
|
cq_zone=int(fields[1]),
|
||||||
|
itu_zone=int(fields[2]),
|
||||||
|
continent=fields[3].upper(),
|
||||||
|
latitude=float(fields[4]),
|
||||||
|
longitude=float(fields[5]),
|
||||||
|
utc_offset=float(fields[6]),
|
||||||
|
primary=primary.lstrip("*"),
|
||||||
|
wae=wae,
|
||||||
|
prefixes=[],
|
||||||
|
entity_id=None,
|
||||||
|
line_no=start_line,
|
||||||
|
)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for token in body.replace("\n", "").split(","):
|
||||||
|
token = token.strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
bare, ov = split_modifiers(token)
|
||||||
|
exact = bare.startswith("=")
|
||||||
|
bare = bare.lstrip("=").strip().upper()
|
||||||
|
if not bare or bare in seen:
|
||||||
|
continue
|
||||||
|
seen.add(bare)
|
||||||
|
ent.prefixes.append((bare, 1 if exact else 0, ov))
|
||||||
|
|
||||||
|
yield ent
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# SQL emission
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SCHEMA = """\
|
||||||
|
CREATE TABLE IF NOT EXISTS dxcc_entities (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
continent CHAR(2) NOT NULL,
|
||||||
|
cq_zone INTEGER NOT NULL,
|
||||||
|
itu_zone INTEGER NOT NULL,
|
||||||
|
latitude DECIMAL(6,2) NOT NULL,
|
||||||
|
longitude DECIMAL(7,2) NOT NULL,
|
||||||
|
utc_offset DECIMAL(4,2) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dxcc_prefixes (
|
||||||
|
prefix VARCHAR(16) NOT NULL,
|
||||||
|
entity_id INTEGER NOT NULL REFERENCES dxcc_entities (id),
|
||||||
|
exact INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (prefix, entity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dxcc_prefixes_entity ON dxcc_prefixes (entity_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def q(value):
|
||||||
|
"""Quote a string for SQL, doubling embedded single quotes."""
|
||||||
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def emit_entities(entities, out):
|
||||||
|
rows = []
|
||||||
|
for e in entities:
|
||||||
|
rows.append((
|
||||||
|
"%d," % e.entity_id,
|
||||||
|
q(e.name) + ",",
|
||||||
|
q(e.primary) + ",",
|
||||||
|
q(e.continent) + ",",
|
||||||
|
"%d," % e.cq_zone,
|
||||||
|
"%d," % e.itu_zone,
|
||||||
|
"%.2f," % e.latitude,
|
||||||
|
"%.2f," % e.longitude,
|
||||||
|
"%.1f" % e.utc_offset,
|
||||||
|
))
|
||||||
|
widths = [max(len(r[i]) for r in rows) for i in range(9)]
|
||||||
|
# Numeric columns look better right-aligned, text columns left-aligned.
|
||||||
|
align = ["<", "<", "<", "<", "<", "<", ">", ">", ">"]
|
||||||
|
|
||||||
|
out.write("INSERT INTO dxcc_entities "
|
||||||
|
"(id, name, primary_prefix, continent, cq_zone, itu_zone, "
|
||||||
|
"latitude, longitude, utc_offset)"
|
||||||
|
" VALUES\n")
|
||||||
|
for n, row in enumerate(rows):
|
||||||
|
cells = [format(cell, "%s%d" % (align[i], widths[i]))
|
||||||
|
for i, cell in enumerate(row)]
|
||||||
|
line = " (" + " ".join(cells).rstrip() + ")"
|
||||||
|
out.write(line + (",\n" if n < len(rows) - 1 else ";\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def emit_prefixes(entities, out, per_line=3):
|
||||||
|
out.write("INSERT INTO dxcc_prefixes "
|
||||||
|
"(prefix, entity_id, exact, cq_zone, itu_zone) VALUES\n")
|
||||||
|
|
||||||
|
def n(v):
|
||||||
|
return "NULL" if v is None else str(v)
|
||||||
|
|
||||||
|
tuples_by_entity = []
|
||||||
|
for e in entities:
|
||||||
|
if e.prefixes:
|
||||||
|
tuples_by_entity.append(
|
||||||
|
["(%s, %d, %d, %s, %s)" % (q(p), e.entity_id, x,
|
||||||
|
n(ov["cq_zone"]), n(ov["itu_zone"]))
|
||||||
|
for p, x, ov in e.prefixes])
|
||||||
|
|
||||||
|
last = len(tuples_by_entity) - 1
|
||||||
|
for i, group in enumerate(tuples_by_entity):
|
||||||
|
for j in range(0, len(group), per_line):
|
||||||
|
slice_ = group[j:j + per_line]
|
||||||
|
is_final = (i == last) and (j + per_line >= len(group))
|
||||||
|
out.write(" " + ", ".join(slice_) + (";\n" if is_final else ",\n"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Convert a cty.dat DXCC country file into SQL INSERT statements.")
|
||||||
|
ap.add_argument("cty", help="path to cty.dat")
|
||||||
|
ap.add_argument("-o", "--output", help="write SQL here (default: stdout)")
|
||||||
|
ap.add_argument("--dxcc-map", metavar="JSON",
|
||||||
|
help='JSON file of {"primary_prefix": entity_id} merged over '
|
||||||
|
"the built-in table")
|
||||||
|
ap.add_argument("--longitude", choices=["east-positive", "as-is"],
|
||||||
|
default="east-positive",
|
||||||
|
help="cty.dat stores longitude positive-west; 'east-positive' "
|
||||||
|
"(default) flips it to the usual convention")
|
||||||
|
ap.add_argument("--utc-offset", choices=["cty", "standard"], default="standard",
|
||||||
|
help="'cty' (default) keeps cty.dat's positive-west offsets; "
|
||||||
|
"'standard' flips them to real UTC offsets")
|
||||||
|
ap.add_argument("--include-wae", action="store_true",
|
||||||
|
help="include WAE/CQ-only entities (needs ids via --dxcc-map)")
|
||||||
|
ap.add_argument("--exclude-exact", action="store_true",
|
||||||
|
help="skip '=CALLSIGN' full-callsign entries entirely")
|
||||||
|
ap.add_argument("--schema", action="store_true",
|
||||||
|
help="emit CREATE TABLE statements first")
|
||||||
|
ap.add_argument("--truncate", action="store_true",
|
||||||
|
help="emit DELETE FROM statements before the inserts")
|
||||||
|
ap.add_argument("--transaction", action="store_true",
|
||||||
|
help="wrap the output in BEGIN / COMMIT")
|
||||||
|
ap.add_argument("--per-line", type=int, default=5, metavar="N",
|
||||||
|
help="prefix tuples per output line (default 5)")
|
||||||
|
ap.add_argument("--strict", action="store_true",
|
||||||
|
help="exit non-zero if any entity has no known DXCC id")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
ids = dict(DXCC_IDS)
|
||||||
|
if args.dxcc_map:
|
||||||
|
with open(args.dxcc_map, encoding="utf-8") as fh:
|
||||||
|
ids.update({k: int(v) for k, v in json.load(fh).items()})
|
||||||
|
|
||||||
|
with open(args.cty, encoding="utf-8", errors="replace") as fh:
|
||||||
|
raw = fh.read()
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
skipped_wae = []
|
||||||
|
unmapped = []
|
||||||
|
for ent in parse_cty(raw):
|
||||||
|
if ent.wae and not args.include_wae:
|
||||||
|
skipped_wae.append(ent)
|
||||||
|
continue
|
||||||
|
ent.entity_id = ids.get(ent.primary)
|
||||||
|
if ent.entity_id is None:
|
||||||
|
unmapped.append(ent)
|
||||||
|
continue
|
||||||
|
if args.longitude == "east-positive":
|
||||||
|
ent.longitude = -ent.longitude or 0.0 # avoid "-0.00"
|
||||||
|
if args.utc_offset == "standard":
|
||||||
|
ent.utc_offset = -ent.utc_offset or 0.0
|
||||||
|
if args.exclude_exact:
|
||||||
|
ent.prefixes = [(p, x, ov) for p, x, ov in ent.prefixes if not x]
|
||||||
|
entities.append(ent)
|
||||||
|
|
||||||
|
entities.sort(key=lambda e: e.entity_id)
|
||||||
|
|
||||||
|
# A prefix must resolve to one entity; cty.dat should not collide, but say
|
||||||
|
# so loudly if a hand-edited file does.
|
||||||
|
owner = OrderedDict()
|
||||||
|
for e in entities:
|
||||||
|
kept = []
|
||||||
|
for p, x, ov in e.prefixes:
|
||||||
|
if p in owner:
|
||||||
|
sys.stderr.write(
|
||||||
|
"warning: prefix %s claimed by both %s and %s; keeping %s\n"
|
||||||
|
% (p, owner[p], e.name, owner[p]))
|
||||||
|
continue
|
||||||
|
owner[p] = e.name
|
||||||
|
kept.append((p, x, ov))
|
||||||
|
e.prefixes = kept
|
||||||
|
|
||||||
|
for e in skipped_wae:
|
||||||
|
sys.stderr.write("note: skipping WAE/CQ-only entity %s (%s)\n"
|
||||||
|
% (e.name, e.primary))
|
||||||
|
for e in unmapped:
|
||||||
|
sys.stderr.write("warning: no DXCC id for %s (primary prefix %s, line %d)\n"
|
||||||
|
% (e.name, e.primary, e.line_no))
|
||||||
|
|
||||||
|
if not entities:
|
||||||
|
sys.stderr.write("error: no entities to write\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
out = open(args.output, "w", encoding="utf-8") if args.output else sys.stdout
|
||||||
|
try:
|
||||||
|
src = os.path.basename(args.cty)
|
||||||
|
out.write("-- " + "-" * 73 + "\n")
|
||||||
|
out.write("-- DXCC entities and prefixes generated from %s by cty2sql.py.\n" % src)
|
||||||
|
out.write("-- %d entities, %d prefixes. Longitude is %s; utc_offset uses the %s.\n"
|
||||||
|
% (len(entities), sum(len(e.prefixes) for e in entities),
|
||||||
|
"positive east" if args.longitude == "east-positive"
|
||||||
|
else "positive west (raw cty.dat)",
|
||||||
|
"cty.dat sign (positive west)" if args.utc_offset == "cty"
|
||||||
|
else "standard UTC sign"))
|
||||||
|
out.write("-- " + "-" * 73 + "\n")
|
||||||
|
if args.schema:
|
||||||
|
out.write(SCHEMA + "\n")
|
||||||
|
if args.transaction:
|
||||||
|
out.write("BEGIN;\n")
|
||||||
|
if args.truncate:
|
||||||
|
out.write("DELETE FROM dxcc_prefixes;\nDELETE FROM dxcc_entities;\n")
|
||||||
|
emit_entities(entities, out)
|
||||||
|
emit_prefixes(entities, out, per_line=max(1, args.per_line))
|
||||||
|
if args.transaction:
|
||||||
|
out.write("COMMIT;\n")
|
||||||
|
finally:
|
||||||
|
if args.output:
|
||||||
|
out.close()
|
||||||
|
|
||||||
|
sys.stderr.write("wrote %d entities and %d prefixes\n"
|
||||||
|
% (len(entities), sum(len(e.prefixes) for e in entities)))
|
||||||
|
return 1 if (args.strict and unmapped) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Build the Hammy reference bundle from the numbered SQL sources.
|
||||||
|
#
|
||||||
|
# The plain `for f in *.sql; do sqlite3 db < $f; done` loop keeps going after a
|
||||||
|
# failure, so one bad file leaves a half-populated database that looks fine
|
||||||
|
# until something queries the missing rows. -bail plus set -e stops at the first
|
||||||
|
# error instead.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB="${1:-hammy-ref.sqlite}"
|
||||||
|
|
||||||
|
# -batch ignores ~/.sqliterc, so output formatting does not depend on whatever
|
||||||
|
# .mode the developer has configured.
|
||||||
|
SQLITE=(sqlite3 -bail -batch)
|
||||||
|
|
||||||
|
# Order matters: 05-dxcc.sql must load before 06-eng-beacons.sql, because
|
||||||
|
# ncdxf_beacons.dxcc_id has a foreign key into dxcc_entities.
|
||||||
|
SOURCES=(*.sql)
|
||||||
|
|
||||||
|
if [ -e "$DB" ]; then
|
||||||
|
echo "removing existing $DB"
|
||||||
|
rm -f "$DB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for f in "${SOURCES[@]}"; do
|
||||||
|
printf ' %-24s' "$f"
|
||||||
|
"${SQLITE[@]}" "$DB" < "$f"
|
||||||
|
echo "ok"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "integrity"
|
||||||
|
fk=$("${SQLITE[@]}" "$DB" 'PRAGMA foreign_key_check;')
|
||||||
|
if [ -n "$fk" ]; then
|
||||||
|
echo " FOREIGN KEY VIOLATIONS:"
|
||||||
|
echo "$fk" | sed 's/^/ /'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " foreign keys ok"
|
||||||
|
echo " integrity $("${SQLITE[@]}" "$DB" 'PRAGMA integrity_check;')"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "row counts"
|
||||||
|
# pragma_table_info returns one row per COLUMN, so counting it gives column
|
||||||
|
# counts. Real row counts need a query per table; generate them and pipe back in.
|
||||||
|
"${SQLITE[@]}" -noheader "$DB" "
|
||||||
|
SELECT 'SELECT '' ' || name || ''' , COUNT(*) FROM ' || name || ';'
|
||||||
|
FROM sqlite_master WHERE type='table' ORDER BY name;
|
||||||
|
" | "${SQLITE[@]}" -noheader -separator ' ' "$DB" | awk '{printf " %-34s %8s\n", $1, $2}'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "compacting"
|
||||||
|
"${SQLITE[@]}" "$DB" 'VACUUM;'
|
||||||
|
|
||||||
|
ls -lh "$DB"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#include <concord/discord.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <hammy/command.h>
|
||||||
|
#include <hammy/commands.h>
|
||||||
|
#include <hammy/job.h>
|
||||||
|
#include <hammy/refdb.h>
|
||||||
|
#include <hammy/utils.h>
|
||||||
|
|
||||||
|
#define HAMMY_ABBR_ECHO_MAX 16
|
||||||
|
#define HAMMY_ABBR_CODE_MAX 3072
|
||||||
|
#define HAMMY_ABBR_TRUNCATED " ... (truncated)"
|
||||||
|
|
||||||
|
void hammy_cmd_abbr(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
|
||||||
|
// Get the qcode argument from the job
|
||||||
|
const char* abbr = hammy_job_get_arg(job, "abbreviation");
|
||||||
|
if (!abbr) {
|
||||||
|
hammy_job_respond(job, client, "Error", "No Abbreviation provided for Abbreviation conversion.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char body[HAMMY_ABBR_CODE_MAX + sizeof(HAMMY_ABBR_TRUNCATED)]; // Generally, since the bottom pointers can only point to max 128-character strings (set as preprocesor header)
|
||||||
|
// So yeah - if we ever change the above for some reason to stupid values... yeah. Technically "unsafe" but yeah.
|
||||||
|
|
||||||
|
const char* str = NULL;
|
||||||
|
const char* context = NULL;
|
||||||
|
|
||||||
|
if (!hammy_refdb_get_abbr(refdb, abbr, &str, &context)) {
|
||||||
|
hammy_job_respond(job, client, "Abbreviation Not Found!", "Failed to find the Abbreviation specified. Please check your query!", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hammy_to_uppercase((char*)abbr);
|
||||||
|
|
||||||
|
snprintf(body, sizeof(body), "Abbreviation: `%s`\nMeaning: `%s`\nUsage Context: `%s`", abbr, (str ? str : "Not Specified"), (context ? context : "Not Specified"));
|
||||||
|
|
||||||
|
hammy_job_respond(job, client, "Abbreviation Conversion", body, false);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
#include <concord/discord.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdarg.h>
|
||||||
|
|
||||||
|
#include <hammy/command.h>
|
||||||
|
#include <hammy/commands.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);
|
||||||
|
#pragma clang diagnostic push
|
||||||
|
#pragma clang diagnostic ignored "-Wformat-nonliteral"
|
||||||
|
int n = vsnprintf(sb->buf + sb->len, sb->cap - sb->len, fmt, ap);
|
||||||
|
#pragma clang diagnostic pop
|
||||||
|
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, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 centerd here extends outside the band.\n");
|
||||||
|
} else if (r.atSegmentEdge) {
|
||||||
|
sb_addf(&sb, "\n> This is exactly a segment boundary, so a signal centerd here straddles both sides.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sb.overflow) {
|
||||||
|
snprintf(body + sizeof(body) - 20, 20, "\n... truncated");
|
||||||
|
}
|
||||||
|
|
||||||
|
hammy_job_respond(job, client, title, body, false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#include <concord/discord.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <hammy/command.h>
|
||||||
|
#include <hammy/commands.h>
|
||||||
|
#include <hammy/job.h>
|
||||||
|
#include <hammy/refdb.h>
|
||||||
|
|
||||||
|
// The reply goes out as an embed description, which Discord caps at 4096
|
||||||
|
// characters, so the two halves get separate budgets that still leave room for
|
||||||
|
// the labels between them. Capping at all is what keeps an unbounded,
|
||||||
|
// user-controlled VLA off the stack: a command option runs to 6000 characters,
|
||||||
|
// and every one of them can expand to eight.
|
||||||
|
#define HAMMY_MORSE_ECHO_MAX 512
|
||||||
|
#define HAMMY_MORSE_CODE_MAX 3072
|
||||||
|
#define HAMMY_MORSE_TRUNCATED " ... (truncated)"
|
||||||
|
|
||||||
|
// Appends one token, space-separated from whatever is already in the buffer and
|
||||||
|
// optionally preceded by a word-gap slash. All or nothing: returns false and
|
||||||
|
// leaves the buffer untouched when the whole thing would not fit, so the caller
|
||||||
|
// can stop without a half-written character on the end.
|
||||||
|
//
|
||||||
|
// len counts bytes excluding the terminator, and the buffer stays terminated
|
||||||
|
// throughout - which is what the strcat-onto-uninitialised-stack version this
|
||||||
|
// replaces got wrong: strcat needs a terminated destination to start from, and
|
||||||
|
// an uninitialised one made the reply random bytes that Discord rejected as
|
||||||
|
// invalid JSON.
|
||||||
|
static bool morse_append(char* buf, size_t cap, size_t* len, bool gap, const char* token) {
|
||||||
|
size_t n = strlen(token);
|
||||||
|
size_t need = (*len > 0 ? 1u : 0u) + (gap ? 2u : 0u) + n;
|
||||||
|
|
||||||
|
if (*len + need + 1 > cap) { return false; }
|
||||||
|
|
||||||
|
if (*len > 0) { buf[(*len)++] = ' '; }
|
||||||
|
|
||||||
|
if (gap) {
|
||||||
|
buf[(*len)++] = '/';
|
||||||
|
buf[(*len)++] = ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(buf + *len, token, n);
|
||||||
|
*len += n;
|
||||||
|
buf[*len] = '\0';
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool morse_is_gap(char c) {
|
||||||
|
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many bytes of s can be kept without exceeding max OR splitting a UTF-8
|
||||||
|
// sequence. Half a sequence would make the JSON body invalid and cost the whole
|
||||||
|
// reply, so the cut walks back to the nearest lead byte.
|
||||||
|
//
|
||||||
|
// Only the echoed input needs this - the generated code is all ASCII.
|
||||||
|
static size_t morse_utf8_trim(const char* s, size_t max) {
|
||||||
|
size_t n = strlen(s);
|
||||||
|
if (n <= max) { return n; }
|
||||||
|
|
||||||
|
// Continuation bytes are 10xxxxxx. Dropping back past them lands either on
|
||||||
|
// ASCII or on the lead byte of the sequence being cut, and that lead byte
|
||||||
|
// is then excluded too, since the kept range is [0, n).
|
||||||
|
n = max;
|
||||||
|
while (n > 0 && ((unsigned char)s[n] & 0xC0) == 0x80) { n--; }
|
||||||
|
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instant command: runs on the gateway thread, sends a fresh response.
|
||||||
|
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) {
|
||||||
|
hammy_job_respond(job, client, "Text Missing!", "No Text provided for Morse code conversion.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Echoed back trimmed rather than whole: sizing a buffer from the option
|
||||||
|
// would put an unbounded, user-controlled allocation on the stack.
|
||||||
|
char echo[HAMMY_MORSE_ECHO_MAX + sizeof(HAMMY_MORSE_TRUNCATED)];
|
||||||
|
size_t echoLen = morse_utf8_trim(text, HAMMY_MORSE_ECHO_MAX);
|
||||||
|
|
||||||
|
memcpy(echo, text, echoLen);
|
||||||
|
echo[echoLen] = '\0';
|
||||||
|
|
||||||
|
if (text[echoLen] != '\0') {
|
||||||
|
memcpy(echo + echoLen, HAMMY_MORSE_TRUNCATED, sizeof(HAMMY_MORSE_TRUNCATED));
|
||||||
|
}
|
||||||
|
|
||||||
|
char morse[HAMMY_MORSE_CODE_MAX + sizeof(HAMMY_MORSE_TRUNCATED)];
|
||||||
|
morse[0] = '\0';
|
||||||
|
|
||||||
|
// Room held back so the truncation note always fits.
|
||||||
|
const size_t cap = sizeof(morse) - (sizeof(HAMMY_MORSE_TRUNCATED) - 1);
|
||||||
|
|
||||||
|
size_t len = 0;
|
||||||
|
bool pendingGap = false;
|
||||||
|
bool truncated = false;
|
||||||
|
|
||||||
|
for (const char* p = text; *p; p++) {
|
||||||
|
// The morse table has no row for whitespace, and rendering it as an
|
||||||
|
// unknown character would lose the word boundaries. Held rather than
|
||||||
|
// emitted on the spot so leading, trailing and repeated spaces do not
|
||||||
|
// stack up slashes.
|
||||||
|
if (morse_is_gap(*p)) {
|
||||||
|
pendingGap = (len > 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* code = NULL;
|
||||||
|
if (!hammy_refdb_get_morse(refdb, *p, &code)) {
|
||||||
|
code = "?"; // Handle unknown characters
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!morse_append(morse, cap, &len, pendingGap, code)) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingGap = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All whitespace, or an empty string: Discord refuses an empty body, so
|
||||||
|
// there is nothing to send back.
|
||||||
|
if (len == 0) {
|
||||||
|
hammy_job_respond(job, client, "Error", "Nothing to convert.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (truncated) {
|
||||||
|
memcpy(morse + len, HAMMY_MORSE_TRUNCATED, sizeof(HAMMY_MORSE_TRUNCATED));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply with the converted Morse code, alongside the text it came from.
|
||||||
|
char body[sizeof(echo) + sizeof(morse) + 32];
|
||||||
|
snprintf(body, sizeof(body), "Original text: `%s`\nMorse: `%s`", echo, morse);
|
||||||
|
|
||||||
|
hammy_job_respond(job, client, "Morse Code Conversion", body, false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#include <concord/discord.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <hammy/command.h>
|
||||||
|
#include <hammy/commands.h>
|
||||||
|
#include <hammy/job.h>
|
||||||
|
#include <hammy/refdb.h>
|
||||||
|
#include <hammy/utils.h>
|
||||||
|
|
||||||
|
// The reply goes out as an embed description, which Discord caps at 4096
|
||||||
|
// characters, so the two halves get separate budgets that still leave room for
|
||||||
|
// the labels between them. Capping at all is what keeps an unbounded,
|
||||||
|
// user-controlled VLA off the stack: a command option runs to 6000 characters,
|
||||||
|
// and every one of them can expand to a whole word.
|
||||||
|
#define HAMMY_PHONETIC_CODE_MAX 1536
|
||||||
|
#define HAMMY_PHONETIC_PRONUNCIATION_MAX 1536
|
||||||
|
#define HAMMY_PHONETIC_TRUNCATED " ... (truncated)"
|
||||||
|
|
||||||
|
// Appends one token, space-separated from whatever is already in the buffer and
|
||||||
|
// optionally preceded by a word-gap slash. All or nothing: returns false and
|
||||||
|
// leaves the buffer untouched when the whole thing would not fit, so the caller
|
||||||
|
// can stop without a half-written word on the end.
|
||||||
|
//
|
||||||
|
// len counts bytes excluding the terminator, and the buffer stays terminated
|
||||||
|
// throughout - which is what the strcat-onto-uninitialised-stack version this
|
||||||
|
// replaces got wrong: strcat needs a terminated destination to start from, and
|
||||||
|
// an uninitialised one made the reply random bytes that Discord rejected as
|
||||||
|
// invalid JSON.
|
||||||
|
static bool phonetic_append(char* buf, size_t cap, size_t* len, bool gap, const char* token) {
|
||||||
|
size_t n = strlen(token);
|
||||||
|
size_t need = (*len > 0 ? 1u : 0u) + (gap ? 2u : 0u) + n;
|
||||||
|
|
||||||
|
if (*len + need + 1 > cap) { return false; }
|
||||||
|
|
||||||
|
if (*len > 0) { buf[(*len)++] = ' '; }
|
||||||
|
|
||||||
|
if (gap) {
|
||||||
|
buf[(*len)++] = '/';
|
||||||
|
buf[(*len)++] = ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(buf + *len, token, n);
|
||||||
|
*len += n;
|
||||||
|
buf[*len] = '\0';
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool phonetic_is_gap(char c) {
|
||||||
|
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instant command: runs on the gateway thread, sends a fresh response.
|
||||||
|
void hammy_cmd_phonetic(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) {
|
||||||
|
hammy_job_respond(job, client, "Text Missing!", "No Text provided for Phonetic conversion.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen(text) >= 128) {
|
||||||
|
hammy_job_respond(job, client, "Text Too Long!", "Text provided is too long! Max. 128 characters.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool showPronunciation = strlen(text) < 12;
|
||||||
|
|
||||||
|
char phonetic[HAMMY_PHONETIC_CODE_MAX + sizeof(HAMMY_PHONETIC_TRUNCATED)];
|
||||||
|
char pronunciation[HAMMY_PHONETIC_PRONUNCIATION_MAX + sizeof(HAMMY_PHONETIC_TRUNCATED)];
|
||||||
|
|
||||||
|
phonetic[0] = '\0';
|
||||||
|
pronunciation[0] = '\0';
|
||||||
|
|
||||||
|
// Room held back so the truncation note always fits.
|
||||||
|
const size_t phoneticCap = sizeof(phonetic) - (sizeof(HAMMY_PHONETIC_TRUNCATED) - 1);
|
||||||
|
const size_t pronunciationCap = sizeof(pronunciation) - (sizeof(HAMMY_PHONETIC_TRUNCATED) - 1);
|
||||||
|
|
||||||
|
size_t phoneticLen = 0;
|
||||||
|
size_t pronunciationLen = 0;
|
||||||
|
bool pendingGap = false;
|
||||||
|
bool truncated = false;
|
||||||
|
|
||||||
|
for (const char* p = text; *p; p++) {
|
||||||
|
// The phonetic table has no row for whitespace, and rendering it as an
|
||||||
|
// unknown character would lose the word boundaries. Held rather than
|
||||||
|
// emitted on the spot so leading, trailing and repeated spaces do not
|
||||||
|
// stack up slashes.
|
||||||
|
if (phonetic_is_gap(*p)) {
|
||||||
|
pendingGap = (phoneticLen > 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* code = NULL;
|
||||||
|
const char* codePronunciation = NULL;
|
||||||
|
if (!hammy_refdb_get_phonetic(refdb, *p, &code, &codePronunciation)) {
|
||||||
|
// Both halves get a placeholder: leaving the pronunciation at NULL
|
||||||
|
// would hand strlen() a null pointer on the next append.
|
||||||
|
code = "?"; // Handle unknown characters
|
||||||
|
codePronunciation = "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two halves are read side by side, so they have to stay on the same
|
||||||
|
// character. A word that fits in one buffer but not the other is rolled
|
||||||
|
// back out of the first rather than left to skew the columns.
|
||||||
|
size_t phoneticMark = phoneticLen;
|
||||||
|
|
||||||
|
if (!phonetic_append(phonetic, phoneticCap, &phoneticLen, pendingGap, code) ||
|
||||||
|
!phonetic_append(pronunciation, pronunciationCap, &pronunciationLen, pendingGap, codePronunciation)) {
|
||||||
|
phoneticLen = phoneticMark;
|
||||||
|
phonetic[phoneticLen] = '\0';
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingGap = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All whitespace, or an empty string: Discord refuses an empty body, so
|
||||||
|
// there is nothing to send back.
|
||||||
|
if (phoneticLen == 0) {
|
||||||
|
hammy_job_respond(job, client, "Error", "Nothing to convert.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (truncated) {
|
||||||
|
memcpy(phonetic + phoneticLen, HAMMY_PHONETIC_TRUNCATED, sizeof(HAMMY_PHONETIC_TRUNCATED));
|
||||||
|
memcpy(pronunciation + pronunciationLen, HAMMY_PHONETIC_TRUNCATED, sizeof(HAMMY_PHONETIC_TRUNCATED));
|
||||||
|
}
|
||||||
|
|
||||||
|
hammy_to_uppercase((char*)text);
|
||||||
|
|
||||||
|
// Reply with the phonetic words and how each of them is spoken.
|
||||||
|
char body[sizeof(phonetic) + sizeof(pronunciation) + 48];
|
||||||
|
if (showPronunciation) {
|
||||||
|
snprintf(body, sizeof(body), "Text: `%s`\nPhonetics: `%s`\nPronunciation: `%s`", text, phonetic, pronunciation);
|
||||||
|
} else {
|
||||||
|
snprintf(body, sizeof(body), "Text: `(truncated)`\nPhonetics: `%s`", phonetic);
|
||||||
|
}
|
||||||
|
|
||||||
|
hammy_job_respond(job, client, "Text to Phonetics Conversion", body, false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#include <concord/discord.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#include <hammy/command.h>
|
||||||
|
#include <hammy/commands.h>
|
||||||
|
#include <hammy/job.h>
|
||||||
|
|
||||||
|
// Discord epoch, for turning a snowflake back into a wall-clock time.
|
||||||
|
#define HAMMY_DISCORD_EPOCH_MS 1420070400000LL
|
||||||
|
|
||||||
|
static int64_t hammy_snowflake_to_ms(u64snowflake id) {
|
||||||
|
return (int64_t)(id >> 22) + HAMMY_DISCORD_EPOCH_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instant command: runs on the gateway thread, sends a fresh response.
|
||||||
|
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.
|
||||||
|
int gatewayMs = discord_get_ping(client);
|
||||||
|
|
||||||
|
// Time from Discord minting the interaction to us handling it. This is the
|
||||||
|
// number that degrades under load, so it is the one worth showing.
|
||||||
|
int64_t handledMs = (int64_t)discord_timestamp(client) - hammy_snowflake_to_ms(job->id);
|
||||||
|
|
||||||
|
char body[256];
|
||||||
|
snprintf(body, sizeof(body),
|
||||||
|
"Gateway: `%d ms`\nHandled in: `%" PRId64 " ms`",
|
||||||
|
gatewayMs, handledMs);
|
||||||
|
|
||||||
|
hammy_job_respond(job, client, "Pong!", body, false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#include <concord/discord.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <hammy/command.h>
|
||||||
|
#include <hammy/commands.h>
|
||||||
|
#include <hammy/job.h>
|
||||||
|
#include <hammy/refdb.h>
|
||||||
|
#include <hammy/utils.h>
|
||||||
|
|
||||||
|
#define HAMMY_QCODE_ECHO_MAX 16
|
||||||
|
#define HAMMY_QCODE_CODE_MAX 3072
|
||||||
|
#define HAMMY_QCODE_TRUNCATED " ... (truncated)"
|
||||||
|
|
||||||
|
void hammy_cmd_q(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
|
||||||
|
// Get the qcode argument from the job
|
||||||
|
const char* qcode = hammy_job_get_arg(job, "q-code");
|
||||||
|
if (!qcode) {
|
||||||
|
hammy_job_respond(job, client, "Error", "No text provided for Q-Code conversion.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char body[HAMMY_QCODE_CODE_MAX + sizeof(HAMMY_QCODE_TRUNCATED)]; // Generally, since the bottom pointers can only point to max 128-character strings (set as preprocesor header)
|
||||||
|
// So yeah - if we ever change the above for some reason to stupid values... yeah. Technically "unsafe" but yeah.
|
||||||
|
|
||||||
|
const char* questionStr = NULL;
|
||||||
|
const char* answerStr = NULL;
|
||||||
|
|
||||||
|
if (!hammy_refdb_get_qcode(refdb, qcode, &questionStr, &answerStr)) {
|
||||||
|
hammy_job_respond(job, client, "Q-Code Not Found!", "Failed to find the Q-Code specified. Please check your query!", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hammy_to_uppercase((char*)qcode);
|
||||||
|
|
||||||
|
snprintf(body, sizeof(body), "Q-Code: `%s`\nQuestion: `%s`\nAnswer: `%s`", qcode, (questionStr ? questionStr : "Not Specified"), (answerStr ? answerStr : "Not Specified"));
|
||||||
|
|
||||||
|
hammy_job_respond(job, client, "Q-Code Conversion", body, false);
|
||||||
|
}
|
||||||
+287
-39
@@ -1,14 +1,118 @@
|
|||||||
|
#include <concord/discord.h>
|
||||||
|
#include <concord/log.h>
|
||||||
|
#include <dlibc/vector.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
#include <hammy/bot.h>
|
#include <hammy/bot.h>
|
||||||
|
#include <hammy/command.h>
|
||||||
|
#include <hammy/job.h>
|
||||||
|
#include <hammy/pool.h>
|
||||||
|
#include <hammy/embeds.h>
|
||||||
|
|
||||||
|
static void hammy_bot_attach(struct discord* client, hammy_bot_t* bot) {
|
||||||
|
discord_set_data(client, bot);
|
||||||
|
}
|
||||||
|
|
||||||
|
static hammy_bot_t* hammy_bot_from_client(struct discord* client) {
|
||||||
|
return (hammy_bot_t*)discord_get_data(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void hammy_bot_on_interaction(struct discord* client, const struct discord_interaction* event) {
|
||||||
|
if (event->type != DISCORD_INTERACTION_APPLICATION_COMMAND) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hammy_bot_t* bot = hammy_bot_from_client(client);
|
||||||
|
if (!bot) {
|
||||||
|
log_warn("[bot] No bot data attached to client. Cannot handle interaction.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* name = (event->data && event->data->name) ? event->data->name : NULL;
|
||||||
|
const hammy_command_t* command = hammy_bot_find_command(bot, name);
|
||||||
|
if (!command) {
|
||||||
|
log_warn("[bot] No command found for interaction '%s'.", name ? name : "(null)");
|
||||||
|
|
||||||
|
struct discord_interaction_response params = {
|
||||||
|
.type = DISCORD_INTERACTION_CHANNEL_MESSAGE_WITH_SOURCE,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct discord_embed embed[1];
|
||||||
|
CCORDcode code;
|
||||||
|
if (hammy_embeds_customerror(client, embed, NULL, "Unknown Command", "I don't know that command!", NULL, 0)) {
|
||||||
|
params.data = &(struct discord_interaction_callback_data){
|
||||||
|
.embeds = &(struct discord_embeds){
|
||||||
|
.size = 1,
|
||||||
|
.array = embed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
code = discord_create_interaction_response(client, event->id, event->token, ¶ms, NULL);
|
||||||
|
} else {
|
||||||
|
params.data = &(struct discord_interaction_callback_data){
|
||||||
|
.content = (char*)"I don't know that command!"
|
||||||
|
};
|
||||||
|
|
||||||
|
code = discord_create_interaction_response(client, event->id, event->token, ¶ms, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code != CCORD_OK) {
|
||||||
|
log_warn("[job] Failed to send interaction error response for interaction %" PRIu64 ": %d", event->id, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hammy_job_t* job = hammy_job_create(bot, event);
|
||||||
|
if (!job) {
|
||||||
|
log_error("[bot] Failed to create job for interaction '%s'.", name ? name : "(null)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instant path: no defer or queue, answered on the spot
|
||||||
|
if (command->instant) {
|
||||||
|
command->handler(job, client, bot->refdb);
|
||||||
|
hammy_job_destroy(&job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deferred path: ACK first, 3 second window by discord, then queue to worker pool
|
||||||
|
struct discord_interaction_response deferred = {
|
||||||
|
.type = DISCORD_INTERACTION_DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
||||||
|
};
|
||||||
|
|
||||||
|
discord_create_interaction_response(client, event->id, event->token, &deferred, NULL);
|
||||||
|
|
||||||
|
switch (hammy_pool_push(bot->pool, job)) {
|
||||||
|
case HAMMY_PUSH_OK:
|
||||||
|
log_info("[bot] Queued job for interaction '%s'.", name ? name : "(null)");
|
||||||
|
break;
|
||||||
|
case HAMMY_PUSH_FULL:
|
||||||
|
log_warn("[bot] Queue pool full. Cannot queue job for interaction '%s'.", name ? name : "(null)");
|
||||||
|
hammy_job_reply(job, client, "Bot is Busy", "I am a bit busy right now! Please try again later.", true);
|
||||||
|
hammy_job_destroy(&job);
|
||||||
|
break;
|
||||||
|
case HAMMY_PUSH_SHUTDOWN:
|
||||||
|
// Also covers the window before on_ready starts the pool, since
|
||||||
|
// hammy_pool_push() reports a NULL pool the same way.
|
||||||
|
log_error("[bot] Worker pool unavailable. Cannot queue job for interaction '%s'.", name ? name : "(null)");
|
||||||
|
hammy_job_reply(job, client, "Bot is Offline", "Hammy is not accepting commands right now. Please try again shortly.", true);
|
||||||
|
hammy_job_destroy(&job);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hammy_bot_t* hammy_bot_create() {
|
hammy_bot_t* hammy_bot_create() {
|
||||||
hammy_bot_t* bot = (hammy_bot_t*)malloc(sizeof(hammy_bot_t));
|
hammy_bot_t* bot = (hammy_bot_t*)calloc(1, sizeof(*bot));
|
||||||
if (!bot) {
|
if (!bot) {
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set defaults
|
// Set defaults
|
||||||
bot->client = NULL;
|
bot->client = NULL;
|
||||||
bot->commands_registered = false;
|
bot->commandsRegistered = false;
|
||||||
bot->commands = vector_create(sizeof(hammy_command_t));
|
bot->commands = vector_create(sizeof(hammy_command_t));
|
||||||
|
|
||||||
// Check if vector allocation errored
|
// Check if vector allocation errored
|
||||||
@@ -17,64 +121,208 @@ hammy_bot_t* hammy_bot_create() {
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vector_set_destructor(bot->commands, &hammy_command_destroy) < 0) {
|
// Open a reference to sqlite
|
||||||
vector_destroy(&bot->commands);
|
bot->refdb = hammy_refdb_open("hammy-ref.sqlite"); // TODO: Probably don't hardcode this?
|
||||||
free(bot);
|
if (!bot->refdb) {
|
||||||
return NULL;
|
log_error("[bot] Failed to open ref to sqlite. Commands requiring it will be unavailable!"); // TODO: Consider making this a hard-fail
|
||||||
}
|
|
||||||
|
|
||||||
// Init the discord client - even though this is an owning reference, the main() and others may call functions on it (if we don't stay singlethread anymore then FIX THIS)
|
|
||||||
bot->client = discord_from_json("config.json");
|
|
||||||
if (!bot->client) {
|
|
||||||
vector_destroy(&bot->commands);
|
|
||||||
free(bot);
|
|
||||||
return NULL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return bot;
|
return bot;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool hammy_bot_set_on_ready(hammy_bot_t* bot, void (*func)(struct discord* client, const struct discord_ready* event)) {
|
bool hammy_bot_add_command(hammy_bot_t* bot, const hammy_command_t* command) {
|
||||||
if (!bot || !func || !bot->client) { return false; }
|
if (!bot || !bot->commands || !command || !command->name) { return false; }
|
||||||
|
|
||||||
discord_set_on_ready(bot->client, func);
|
// Copy the command into the vector. The command owns nothing, so this is safe.
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hammy_bot_run(hammy_bot_t* bot) {
|
|
||||||
if (!bot || !bot->client) { return false; }
|
|
||||||
|
|
||||||
discord_run(bot->client);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hammy_bot_add_command(hammy_bot_t* bot, hammy_command_t* command) {
|
|
||||||
if (!bot || !command) { return false; }
|
|
||||||
|
|
||||||
// Push into commands vector
|
|
||||||
if (vector_push_back(bot->commands, command) < 0) {
|
if (vector_push_back(bot->commands, command) < 0) {
|
||||||
|
log_error("[bot] Failed to add command '%s' to bot.", command->name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info("[bot] Loaded command '%s'.", command->name);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hammy_bot_load_builtins(hammy_bot_t* bot) {
|
||||||
|
if (!bot) { return false; }
|
||||||
|
|
||||||
|
size_t count = 0;
|
||||||
|
const hammy_command_t* builtins = hammy_command_builtins(&count);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < count; i++) {
|
||||||
|
if (!hammy_bot_add_command(bot, &builtins[i])) {
|
||||||
|
log_error("[bot] Failed to load builtin command '%s'.", builtins[i].name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info("[bot] Loaded %zu builtin commands.", count);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hammy_command_t* hammy_bot_find_command(const hammy_bot_t* bot, const char* name) {
|
||||||
|
if (!bot || !bot->commands || !name) { return NULL; }
|
||||||
|
|
||||||
|
// Vector guarantees contiguous and gapless storage, so a plain-array search is fine.
|
||||||
|
const hammy_command_t* commands = (const hammy_command_t*)vector_as_c_array(bot->commands);
|
||||||
|
|
||||||
|
return hammy_command_find(commands, vector_size(bot->commands), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hammy_bot_start_pool(hammy_bot_t* bot, size_t nWorkers, size_t queueCap) {
|
||||||
|
if (!bot || !bot->client) { return false; }
|
||||||
|
if (bot->pool) {
|
||||||
|
// A reconnect re-fires READY, so this is routine rather than a problem.
|
||||||
|
log_debug("[bot] Worker pool already started. No-op.");
|
||||||
|
return true; // Already started
|
||||||
|
}
|
||||||
|
|
||||||
|
bot->pool = hammy_pool_create(bot->client, nWorkers, queueCap);
|
||||||
|
|
||||||
|
if (!bot->pool) {
|
||||||
|
log_error("[bot] Failed to create worker pool.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool hammy_bot_register_commands(hammy_bot_t* bot) {
|
bool hammy_bot_deregister_all_commands(hammy_bot_t* bot) {
|
||||||
if (!bot) { return false; }
|
// TODO: This leaks and I don't know why. Figure it out.
|
||||||
|
if (!bot || !bot->client) { return false; }
|
||||||
|
|
||||||
// TODO: Figure out registration logic
|
struct discord_application_commands cmds = {0};
|
||||||
|
struct discord_ret_application_commands ret = { .sync = &cmds };
|
||||||
|
|
||||||
|
if (discord_get_global_application_commands(bot->client, bot->appId, &ret) == CCORD_OK) {
|
||||||
|
for (int i = 0; i < cmds.size; i++) {
|
||||||
|
discord_delete_global_application_command(bot->client, bot->appId, cmds.array[i].id, NULL);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log_error("[bot] Failed to deregister global commands!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* devGuild = getenv("HAMMY_DEV_GUILD");
|
||||||
|
u64snowflake guildId = devGuild ? (u64snowflake)strtoull(devGuild, NULL, 10) : 0;
|
||||||
|
|
||||||
|
if (guildId) {
|
||||||
|
if (discord_get_guild_application_commands(bot->client, bot->appId, guildId, &ret) == CCORD_OK) {
|
||||||
|
for (int i = 0; i < cmds.size; i++) {
|
||||||
|
discord_delete_guild_application_command(bot->client, bot->appId, guildId, cmds.array[i].id, NULL);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log_error("[bot] Failed to deregister dev guild commands!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot->commandsRegistered = false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hammy_bot_register_commands(hammy_bot_t* bot) {
|
||||||
|
if (!bot || !bot->client) { return false; }
|
||||||
|
if (bot->commandsRegistered) { return true; } // Already registered
|
||||||
|
|
||||||
|
if (!bot->appId) {
|
||||||
|
log_error("[bot] No application ID set. Cannot register commands. This should be set in the on_ready() callback.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* devGuild = getenv("HAMMY_DEV_GUILD");
|
||||||
|
u64snowflake guildId = devGuild ? (u64snowflake)strtoull(devGuild, NULL, 10) : 0;
|
||||||
|
|
||||||
|
size_t count = vector_size(bot->commands);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < count; i++) {
|
||||||
|
const hammy_command_t* cmd = (const hammy_command_t*)vector_get_const(bot->commands, i);
|
||||||
|
|
||||||
|
if (!cmd || !cmd->name || !cmd->description) {
|
||||||
|
log_warn("[bot] Invalid command at index %zu. Cannot register with Discord. Skipping!", i);
|
||||||
|
continue; // Skip this command, but try to register the rest
|
||||||
|
}
|
||||||
|
|
||||||
|
CCORDcode code;
|
||||||
|
|
||||||
|
if (guildId) {
|
||||||
|
struct discord_create_guild_application_command params = {
|
||||||
|
.name = (char*)cmd->name,
|
||||||
|
.description = (char*)cmd->description,
|
||||||
|
.options = cmd->options
|
||||||
|
};
|
||||||
|
|
||||||
|
code = discord_create_guild_application_command(bot->client, bot->appId, guildId, ¶ms, NULL);
|
||||||
|
} else {
|
||||||
|
struct discord_create_global_application_command params = {
|
||||||
|
.name = (char*)cmd->name,
|
||||||
|
.description = (char*)cmd->description,
|
||||||
|
.options = cmd->options
|
||||||
|
};
|
||||||
|
|
||||||
|
code = discord_create_global_application_command(bot->client, bot->appId, ¶ms, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passing NULL for the return handle makes these fire-and-forget, so
|
||||||
|
// a successful enqueue reports CCORD_PENDING rather than CCORD_OK.
|
||||||
|
if (code != CCORD_OK && code != CCORD_PENDING) {
|
||||||
|
log_error("[bot] Failed to register command '%s' with Discord. Error code: %d", cmd->name, code);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot->commandsRegistered = true;
|
||||||
|
log_info("[bot] Successfully registered %zu commands with Discord %s.", count, guildId ? "to dev guild" : "globally");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hammy_bot_set_on_ready(hammy_bot_t* bot, void (*func)(struct discord* client, const struct discord_ready* event)) {
|
||||||
|
if (!bot || !func || !bot->client) { return false; }
|
||||||
|
|
||||||
|
discord_set_on_ready(bot->client, func);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hammy_bot_run(hammy_bot_t* bot) {
|
||||||
|
if (!bot || !bot->client) { return false; }
|
||||||
|
|
||||||
|
hammy_bot_attach(bot->client, bot);
|
||||||
|
discord_set_on_interaction_create(bot->client, &hammy_bot_on_interaction);
|
||||||
|
|
||||||
|
return discord_run(bot->client) == CCORD_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool hammy_bot_destroy(hammy_bot_t** bot) {
|
bool hammy_bot_destroy(hammy_bot_t** bot) {
|
||||||
if (!bot || !(*bot)) { return false; }
|
if (!bot || !(*bot)) { return false; }
|
||||||
|
|
||||||
if ((*bot)->client) {
|
hammy_bot_t* b = *bot;
|
||||||
discord_cleanup((*bot)->client);
|
|
||||||
|
// Workers hold clones of the client
|
||||||
|
if (b->pool) {
|
||||||
|
hammy_pool_destroy(&b->pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No element destructor, commands own nothing.
|
||||||
|
if (b->commands) {
|
||||||
|
vector_destroy(&b->commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b->client) {
|
||||||
|
discord_cleanup(b->client);
|
||||||
|
b->client = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b->refdb) {
|
||||||
|
hammy_refdb_close(&b->refdb);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Potential other stuff here
|
|
||||||
vector_destroy(&(*bot)->commands); // commands is NULL form hereon, calls destructors on commands automatically
|
|
||||||
free(*bot);
|
free(*bot);
|
||||||
*bot = NULL;
|
*bot = NULL;
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-15
@@ -1,24 +1,118 @@
|
|||||||
|
#include <stddef.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
#include <hammy/command.h>
|
#include <hammy/command.h>
|
||||||
|
#include <hammy/commands.h> // Table
|
||||||
|
|
||||||
hammy_command_t* hammy_command_create() {
|
// Handlers live in src/hammy/commands/. Declared here rather than in a header
|
||||||
hammy_command_t* cmd = (hammy_command_t*)malloc(sizeof(hammy_command_t));
|
// so adding a command touches exactly two places: its own .c file and this
|
||||||
if (!cmd) {
|
// table.
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: set defaults
|
// 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[] = {
|
||||||
|
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"text",
|
||||||
|
.description = (char*)"Text to convert to Morse code", .required = true },
|
||||||
|
};
|
||||||
|
|
||||||
return cmd;
|
static struct discord_application_command_options morse_opts_struct = {
|
||||||
|
.size = 1,
|
||||||
|
.array = morse_opts
|
||||||
|
};
|
||||||
|
|
||||||
|
static struct discord_application_command_option freq_opts[] = {
|
||||||
|
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"frequency",
|
||||||
|
.description = (char*)"Frequency in MHz", .required = true },
|
||||||
|
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"country",
|
||||||
|
.description = (char*)"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
|
||||||
|
};
|
||||||
|
|
||||||
|
static struct discord_application_command_option q_opts[] = {
|
||||||
|
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"q-code",
|
||||||
|
.description = (char*)"Q-Code to convert to Question/Answer", .required = true },
|
||||||
|
};
|
||||||
|
|
||||||
|
static struct discord_application_command_options q_opts_struct = {
|
||||||
|
.size = 1,
|
||||||
|
.array = q_opts
|
||||||
|
};
|
||||||
|
|
||||||
|
static struct discord_application_command_option abbr_opts[] = {
|
||||||
|
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"abbreviation",
|
||||||
|
.description = (char*)"Abbreviation to convert to Meaning", .required = true },
|
||||||
|
};
|
||||||
|
|
||||||
|
static struct discord_application_command_options abbr_opts_struct = {
|
||||||
|
.size = 1,
|
||||||
|
.array = abbr_opts
|
||||||
|
};
|
||||||
|
|
||||||
|
static struct discord_application_command_option phonetic_opts[] = {
|
||||||
|
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"text",
|
||||||
|
.description = (char*)"Text to convert to Phonetics", .required = true },
|
||||||
|
};
|
||||||
|
|
||||||
|
static struct discord_application_command_options phonetic_opts_struct = {
|
||||||
|
.size = 1,
|
||||||
|
.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.
|
||||||
|
//
|
||||||
|
// Field order: name, description, options, handler, instant.
|
||||||
|
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 },
|
||||||
|
{ "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:
|
||||||
|
// { "grid", "Maidenhead locator conversions", &grid_opts, &hammy_cmd_grid, true },
|
||||||
|
// { "morse", "Text to and from Morse code", &morse_opts, &hammy_cmd_morse, true },
|
||||||
|
// { "band", "What band is a frequency in", &band_opts, &hammy_cmd_band, true },
|
||||||
|
//
|
||||||
|
// Anything touching the backend API, a database or the network is NOT
|
||||||
|
// instant:
|
||||||
|
// { "call", "Look up a callsign", &call_opts, &hammy_cmd_call, false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const hammy_command_t* hammy_command_builtins(size_t* outCount) {
|
||||||
|
if (outCount)
|
||||||
|
*outCount = sizeof(hammy_builtin_commands) / sizeof(hammy_builtin_commands[0]);
|
||||||
|
|
||||||
|
return hammy_builtin_commands;
|
||||||
}
|
}
|
||||||
|
|
||||||
void hammy_command_destroy(void* cmd) {
|
const hammy_command_t* hammy_command_find(const hammy_command_t* commands, size_t count,
|
||||||
if (!cmd) {
|
const char* name) {
|
||||||
return;
|
if (!commands || !name) return NULL;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < count; i++) {
|
||||||
|
if (commands[i].name && strcmp(commands[i].name, name) == 0)
|
||||||
|
return &commands[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd = (hammy_command_t*)cmd;
|
return NULL;
|
||||||
|
}
|
||||||
// TODO: Other things here eventually
|
|
||||||
|
bool hammy_command_is_instant(const hammy_command_t* command) {
|
||||||
free(cmd);
|
return command && command->instant;
|
||||||
}
|
}
|
||||||
|
|||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
#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; }
|
||||||
|
|
||||||
|
#pragma clang diagnostic push
|
||||||
|
#pragma clang diagnostic ignored "-Wbad-function-cast" // suppresses the dumb floor() cast to int warning
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma clang diagnostic pop
|
||||||
+80
-18
@@ -4,7 +4,10 @@
|
|||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <hammy/bot.h>
|
||||||
|
#include <hammy/command.h>
|
||||||
#include <hammy/job.h>
|
#include <hammy/job.h>
|
||||||
|
#include <hammy/embeds.h>
|
||||||
|
|
||||||
// strdup() is POSIX, so we'll keep a local and keep the code portable.
|
// strdup() is POSIX, so we'll keep a local and keep the code portable.
|
||||||
static char* hammy_strdup(const char* src) {
|
static char* hammy_strdup(const char* src) {
|
||||||
@@ -19,6 +22,17 @@ static char* hammy_strdup(const char* src) {
|
|||||||
return dst;
|
return dst;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logs a failed send, and only a failed send. Concord returns CCORD_PENDING for
|
||||||
|
// an asynchronous request (which is every request made with a NULL ret, i.e.
|
||||||
|
// all of ours) - the request has been queued, not rejected, so treating it as
|
||||||
|
// an error warned on every single reply that actually worked.
|
||||||
|
static void hammy_job_check_send(const hammy_job_t* job, CCORDcode code) {
|
||||||
|
if (code == CCORD_OK || code == CCORD_PENDING) { return; }
|
||||||
|
|
||||||
|
log_warn("[job] Failed to send interaction response for interaction %" PRIu64 ": %s (%d)",
|
||||||
|
job->id, ccord_strerror(code), code);
|
||||||
|
}
|
||||||
|
|
||||||
// Pulls the invoking user out of the event. Guild interactions carry it under
|
// Pulls the invoking user out of the event. Guild interactions carry it under
|
||||||
// member->user, DM interactions under user directly.
|
// member->user, DM interactions under user directly.
|
||||||
static u64snowflake hammy_job_extract_user(const struct discord_interaction* event) {
|
static u64snowflake hammy_job_extract_user(const struct discord_interaction* event) {
|
||||||
@@ -28,17 +42,19 @@ static u64snowflake hammy_job_extract_user(const struct discord_interaction* eve
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
hammy_job_t* hammy_job_create(struct discord* client, const struct discord_interaction* event) {
|
hammy_job_t* hammy_job_create(hammy_bot_t* bot, const struct discord_interaction* event) {
|
||||||
if (!client || !event) { return NULL; }
|
if (!bot || !bot->client || !event) { return NULL; }
|
||||||
|
|
||||||
hammy_job_t* job = (hammy_job_t*)calloc(1, sizeof(*job));
|
hammy_job_t* job = (hammy_job_t*)calloc(1, sizeof(*job));
|
||||||
if (!job) { return NULL; }
|
if (!job) { return NULL; }
|
||||||
|
|
||||||
|
job->bot = bot;
|
||||||
job->id = event->id;
|
job->id = event->id;
|
||||||
job->appId = event->application_id;
|
|
||||||
job->user = hammy_job_extract_user(event);
|
job->user = hammy_job_extract_user(event);
|
||||||
job->token = hammy_strdup(event->token);
|
job->token = hammy_strdup(event->token);
|
||||||
job->queuedAt = (int64_t)discord_timestamp(client);
|
job->queuedAt = (int64_t)discord_timestamp(bot->client);
|
||||||
|
|
||||||
|
job->appId = event->application_id ? event->application_id : bot->appId;
|
||||||
|
|
||||||
if (!job->token) {
|
if (!job->token) {
|
||||||
goto fail;
|
goto fail;
|
||||||
@@ -121,27 +137,73 @@ int64_t hammy_job_age_ms(const hammy_job_t* job, struct discord* client) {
|
|||||||
return (int64_t)discord_timestamp(client) - job->queuedAt;
|
return (int64_t)discord_timestamp(client) - job->queuedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
void hammy_job_reply(const hammy_job_t* job, struct discord* client, const char* content) {
|
void hammy_job_reply(const hammy_job_t* job, struct discord* client, const char* title, const char* content, bool isError) {
|
||||||
if (!job || !client || !content) { return; }
|
if (!job || !client || !content || !title) { return; }
|
||||||
|
|
||||||
// TODO: Embeds
|
// Editing, not creating: every caller is on the deferred path, where the
|
||||||
struct discord_edit_original_interaction_response params = {
|
// gateway thread already sent DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE. A second
|
||||||
.content = (char*)content
|
// create on the same interaction is refused with "Interaction has already
|
||||||
};
|
// been acknowledged" (40060). Instant handlers want hammy_job_respond().
|
||||||
|
struct discord_edit_original_interaction_response params = { 0 };
|
||||||
|
|
||||||
CCORDcode code = discord_edit_original_interaction_response(client, job->appId, job->token, ¶ms, NULL);
|
// Both must outlive the call, so neither can be a compound literal inside
|
||||||
if (code != CCORD_OK) {
|
// the branch below.
|
||||||
log_warn("[job] Failed to edit response for interaction %" PRIu64 ": %d", job->id, code);
|
struct discord_embed embed[1];
|
||||||
|
struct discord_embeds embeds = { .size = 1, .array = embed };
|
||||||
|
|
||||||
|
if (hammy_embeds_customembed(client, embed, NULL, title, content, NULL, 0, isError ? 0xFF0000 : 0x00FF00)) {
|
||||||
|
params.embeds = &embeds;
|
||||||
|
} else {
|
||||||
|
// Only reachable on a bad argument, but a plain-text body still beats
|
||||||
|
// sending nothing at all.
|
||||||
|
params.content = (char*)content;
|
||||||
|
}
|
||||||
|
|
||||||
|
hammy_job_check_send(job, discord_edit_original_interaction_response(client, job->appId, job->token, ¶ms, NULL));
|
||||||
|
}
|
||||||
|
|
||||||
|
void hammy_job_respond(const hammy_job_t* job, struct discord* client, const char* title, const char* content, bool isError) {
|
||||||
|
if (!job || !client || !content || !title) { return; }
|
||||||
|
|
||||||
|
struct discord_embed embed[1];
|
||||||
|
if (hammy_embeds_customembed(client, embed, NULL, title, content, NULL, 0, isError ? 0xFF0000 : 0x00FF00)) {
|
||||||
|
struct discord_interaction_response params = {
|
||||||
|
.type = DISCORD_INTERACTION_CHANNEL_MESSAGE_WITH_SOURCE,
|
||||||
|
.data = &(struct discord_interaction_callback_data){
|
||||||
|
.embeds = &(struct discord_embeds){
|
||||||
|
.size = 1,
|
||||||
|
.array = embed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
hammy_job_check_send(job, discord_create_interaction_response(client, job->id, job->token, ¶ms, NULL));
|
||||||
|
} else {
|
||||||
|
struct discord_interaction_response params = {
|
||||||
|
.type = DISCORD_INTERACTION_CHANNEL_MESSAGE_WITH_SOURCE,
|
||||||
|
.data = &(struct discord_interaction_callback_data){
|
||||||
|
.content = (char*)content
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
hammy_job_check_send(job, discord_create_interaction_response(client, job->id, job->token, ¶ms, NULL));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void hammy_job_run(hammy_job_t* job, struct discord* client) {
|
void hammy_job_run(hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
|
||||||
if (!job || !client) { return; }
|
if (!job || !client) { return; }
|
||||||
|
if (!refdb) {
|
||||||
|
// TODO: Consider making this a hard-fail
|
||||||
|
log_warn("[job] Ran command '%s' for interaction %" PRIu64 " without a valid refdb! SQLite functions will be unavailable!", job->command ? job->command : "unknown", job->id);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: look job->command up in the bot's command vector and call its
|
|
||||||
// handler with (job, client). Placeholder until command.h grows a
|
|
||||||
// dispatch entry point.
|
|
||||||
log_info("[job] Running command '%s' for interaction %" PRIu64, job->command ? job->command : "unknown", job->id);
|
log_info("[job] Running command '%s' for interaction %" PRIu64, job->command ? job->command : "unknown", job->id);
|
||||||
|
const hammy_command_t* command = hammy_bot_find_command(job->bot, job->command);
|
||||||
|
if (!command || !command->handler) {
|
||||||
|
log_warn("[job] No handler found for command '%s'.", job->command ? job->command : "unknown");
|
||||||
|
hammy_job_reply(job, client, "Unknown Command", "I don't know that command!", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
hammy_job_reply(job, client, "This is a placeholder reply. The command handler is not yet implemented.");
|
command->handler(job, client, refdb);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-2
@@ -2,6 +2,7 @@
|
|||||||
#include <concord/log.h>
|
#include <concord/log.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include <hammy/bot.h>
|
||||||
#include <hammy/job.h>
|
#include <hammy/job.h>
|
||||||
#include <hammy/pool.h>
|
#include <hammy/pool.h>
|
||||||
#include <hammy/worker.h>
|
#include <hammy/worker.h>
|
||||||
@@ -104,13 +105,22 @@ static void hammy_pool_stop(hammy_pool_t* pool, bool drain) {
|
|||||||
|
|
||||||
pool->shutdown = true;
|
pool->shutdown = true;
|
||||||
|
|
||||||
if (!drain) {
|
// Detach the queue under the lock, but apologise outside it - a REST
|
||||||
|
// enqueue has no business happening while the workers are blocked on it.
|
||||||
|
hammy_job_t** dropped = NULL;
|
||||||
|
size_t nDropped = 0;
|
||||||
|
|
||||||
|
if (!drain && pool->count > 0) {
|
||||||
|
dropped = (hammy_job_t**)calloc(pool->count, sizeof(*dropped));
|
||||||
|
|
||||||
while (pool->count > 0) {
|
while (pool->count > 0) {
|
||||||
hammy_job_t* job = pool->jobs[pool->head];
|
hammy_job_t* job = pool->jobs[pool->head];
|
||||||
pool->head = (pool->head + 1) % pool->cap;
|
pool->head = (pool->head + 1) % pool->cap;
|
||||||
pool->count--;
|
pool->count--;
|
||||||
|
|
||||||
hammy_job_destroy(&job);
|
// Out of memory on the way out is not worth a leak, so drop it quietly.
|
||||||
|
if (dropped) { dropped[nDropped++] = job; }
|
||||||
|
else { hammy_job_destroy(&job); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +128,20 @@ static void hammy_pool_stop(hammy_pool_t* pool, bool drain) {
|
|||||||
pthread_cond_broadcast(&pool->notEmpty);
|
pthread_cond_broadcast(&pool->notEmpty);
|
||||||
pthread_mutex_unlock(&pool->lock);
|
pthread_mutex_unlock(&pool->lock);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < nDropped; i++) {
|
||||||
|
hammy_job_t* job = dropped[i];
|
||||||
|
|
||||||
|
// The job's bot back-reference carries the original client, which is
|
||||||
|
// the one this thread is allowed to serialise through.
|
||||||
|
if (job->bot && job->bot->client) {
|
||||||
|
hammy_job_reply(job, job->bot->client, "Bot is Shutting Down", "Hammy is shutting down, so this command was dropped. Please try again once it is back.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
hammy_job_destroy(&job);
|
||||||
|
}
|
||||||
|
|
||||||
|
free(dropped);
|
||||||
|
|
||||||
for (size_t i = 0; i < pool->nWorkers; i++) {
|
for (size_t i = 0; i < pool->nWorkers; i++) {
|
||||||
hammy_worker_join(&pool->workers[i]);
|
hammy_worker_join(&pool->workers[i]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,850 @@
|
|||||||
|
#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[] =
|
||||||
|
"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"
|
||||||
|
" FROM dxcc_prefixes p JOIN dxcc_entities e ON e.id = p.entity_id"
|
||||||
|
" WHERE p.prefix = ?1 AND p.exact = 1"
|
||||||
|
" LIMIT 1";
|
||||||
|
|
||||||
|
// One candidate at a time, called longest-first by the caller.
|
||||||
|
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"
|
||||||
|
" FROM dxcc_prefixes p JOIN dxcc_entities e ON e.id = p.entity_id"
|
||||||
|
" WHERE p.prefix = ?1 AND p.exact = 0"
|
||||||
|
" LIMIT 1";
|
||||||
|
|
||||||
|
static const char SQL_VERSION[] =
|
||||||
|
"SELECT value FROM ref_meta WHERE key = 'bundle_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_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";
|
||||||
|
|
||||||
|
// Resolve Q-Code to question/answer
|
||||||
|
static const char SQL_QCODE[] =
|
||||||
|
"SELECT question, answer FROM qcodes WHERE code = UPPER(?1) LIMIT 1";
|
||||||
|
|
||||||
|
// Resolve character to phonetic
|
||||||
|
static const char SQL_PHONETIC[] =
|
||||||
|
"SELECT word, pronunciation FROM phonetics WHERE letter = UPPER(?1) LIMIT 1";
|
||||||
|
|
||||||
|
// Resolve abbr to meaning
|
||||||
|
static const char SQL_ABBR[] =
|
||||||
|
"SELECT meaning, context FROM abbreviations WHERE abbr = UPPER(?1) LIMIT 1";
|
||||||
|
|
||||||
|
// Authorizer: the connection may read, and nothing else.
|
||||||
|
//
|
||||||
|
// SQLITE_OPEN_READONLY protects the MAIN database file. It does not stop
|
||||||
|
// "ATTACH DATABASE 'somewhere.db' AS x" - an attached database is a separate
|
||||||
|
// file with its own flags, so a read-only connection is not automatically a
|
||||||
|
// read-only process. SQLITE_LIMIT_ATTACHED below closes that, and this
|
||||||
|
// authorizer closes it again, because defence in depth costs nothing here.
|
||||||
|
static int hammy_refdb_authorizer(void* unused, int action,
|
||||||
|
const char* a1, const char* a2,
|
||||||
|
const char* dbname, const char* trigger) {
|
||||||
|
(void)unused; (void)a1; (void)a2; (void)dbname; (void)trigger;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case SQLITE_SELECT:
|
||||||
|
case SQLITE_READ:
|
||||||
|
case SQLITE_FUNCTION: // COALESCE, length(), upper() and friends
|
||||||
|
return SQLITE_OK;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// INSERT, UPDATE, DELETE, ATTACH, DETACH, CREATE/DROP anything,
|
||||||
|
// PRAGMA, transactions - all refused at prepare time.
|
||||||
|
return SQLITE_DENY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(stQCode, SQL_QCODE),
|
||||||
|
HAMMY_STMT(stPhonetic, SQL_PHONETIC),
|
||||||
|
HAMMY_STMT(stAbbr, SQL_ABBR),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma GCC diagnostic push
|
||||||
|
#pragma GCC diagnostic ignored "-Wjump-misses-init" // suppresses the "jumped initialization of varaible" warning, has no effect here
|
||||||
|
hammy_refdb_t* hammy_refdb_open(const char* path) {
|
||||||
|
if (!path) { return NULL; }
|
||||||
|
|
||||||
|
hammy_refdb_t* db = (hammy_refdb_t*)calloc(1, sizeof(*db));
|
||||||
|
if (!db) { return NULL; }
|
||||||
|
|
||||||
|
// immutable=1 promises SQLite the fill won't change while it's open. Skips locks and change-counter checks
|
||||||
|
// Faster and stricter than plain read-only
|
||||||
|
char uri[1024];
|
||||||
|
snprintf(uri, sizeof(uri), "file:%s?mode=ro&immutable=1", path);
|
||||||
|
|
||||||
|
// NOMUTEX - exactly one thread uses this handle, avoid serialization overhead. Safe only because of the one-per-thread rule.
|
||||||
|
int flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_URI;
|
||||||
|
|
||||||
|
if (sqlite3_open_v2(uri, &db->handle, flags, NULL) != SQLITE_OK) {
|
||||||
|
log_error("[refdb] cannot open %s: %s", path, db->handle ? sqlite3_errmsg(db->handle) : "out of memory");
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No ATTACH; closes a way a read-only connection could still open writable files.
|
||||||
|
sqlite3_limit(db->handle, SQLITE_LIMIT_ATTACHED, 0);
|
||||||
|
|
||||||
|
// Bound the damage of a pathological query. Turns a runaway into an error instead of a stall. Keeps plenty of headroom for actual lookups.
|
||||||
|
sqlite3_limit(db->handle, SQLITE_LIMIT_SQL_LENGTH, 8192);
|
||||||
|
sqlite3_limit(db->handle, SQLITE_LIMIT_EXPR_DEPTH, 100);
|
||||||
|
sqlite3_limit(db->handle, SQLITE_LIMIT_LIKE_PATTERN_LENGTH, 256);
|
||||||
|
sqlite3_limit(db->handle, SQLITE_LIMIT_VARIABLE_NUMBER, 32);
|
||||||
|
|
||||||
|
// Refuse schema-corrupting tricks (e.g. PRAGMA writable_schema) and stop the schema itself from invoking unvetted functions.
|
||||||
|
sqlite3_db_config(db->handle, SQLITE_DBCONFIG_DEFENSIVE, 1, NULL);
|
||||||
|
sqlite3_db_config(db->handle, SQLITE_DBCONFIG_TRUSTED_SCHEMA, 0, NULL);
|
||||||
|
|
||||||
|
// The bundle is small enough to just map memory-whole. Avoids a read() per page. Set before authorizer since it denies PRAGMA.
|
||||||
|
sqlite3_exec(db->handle, "PRAGMA mmap_size = 67108864;", NULL, NULL, NULL);
|
||||||
|
|
||||||
|
// TODO: PLEASE chmod 444 OR SOMETHING AND DON'T RUN AS THE SQLITE DB FILE OWNER - THAT'S THE ONLY REAL PROTECTION
|
||||||
|
|
||||||
|
sqlite3_set_authorizer(db->handle, &hammy_refdb_authorizer, NULL);
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
if (sqlite3_step(st) == SQLITE_ROW) {
|
||||||
|
const unsigned char* v = sqlite3_column_text(st, 0);
|
||||||
|
if (v) {
|
||||||
|
snprintf(db->version, sizeof(db->version), "%s", (const char*)v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db;
|
||||||
|
|
||||||
|
fail:
|
||||||
|
hammy_refdb_close(&db);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
#pragma GCC diagnostic pop
|
||||||
|
|
||||||
|
bool hammy_refdb_close(hammy_refdb_t** db) {
|
||||||
|
if (!db || !(*db)) { return false; }
|
||||||
|
|
||||||
|
hammy_refdb_t* d = *db;
|
||||||
|
|
||||||
|
// Statements must be finalized before the connection closes, or we're in deep shit
|
||||||
|
// (sqlite3_close() returns SQLITE_BUSY and the handle leaks)
|
||||||
|
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); }
|
||||||
|
|
||||||
|
free(d);
|
||||||
|
*db = NULL;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* hammy_refdb_version(hammy_refdb_t* db) {
|
||||||
|
return (db && db->version[0]) ? db->version : NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uppercase, strip whitespace, keep only characters that appear in callsigns (voodoo).
|
||||||
|
static void normalise(const char* in, char* out, size_t cap) {
|
||||||
|
size_t j = 0;
|
||||||
|
|
||||||
|
for (size_t i = 0; in[i] && j + 1 < cap; i++) {
|
||||||
|
unsigned char c = (unsigned char)in[i];
|
||||||
|
|
||||||
|
if (c >= 'a' && c <= 'z') { c = (unsigned char)(c - 'a' + 'A'); }
|
||||||
|
|
||||||
|
if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '/') {
|
||||||
|
out[j++] = (char)c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out[j] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fill(sqlite3_stmt* st, hammy_dxcc_t* out, bool exact) {
|
||||||
|
out->entityId = sqlite3_column_int(st, 0);
|
||||||
|
|
||||||
|
const unsigned char* name = sqlite3_column_text(st, 1);
|
||||||
|
const unsigned char* cont = sqlite3_column_text(st, 2);
|
||||||
|
const unsigned char* pfx = sqlite3_column_text(st, 8);
|
||||||
|
|
||||||
|
snprintf(out->name, sizeof(out->name), "%s", name ? (const char*)name : "");
|
||||||
|
snprintf(out->continent, sizeof(out->continent), "%s", cont ? (const char*)cont : "");
|
||||||
|
snprintf(out->matchedPrefix, sizeof(out->matchedPrefix), "%s", pfx ? (const char*)pfx : "");
|
||||||
|
|
||||||
|
out->cqZone = sqlite3_column_int(st, 3);
|
||||||
|
out->ituZone = sqlite3_column_int(st, 4);
|
||||||
|
out->latitude = sqlite3_column_double(st, 5);
|
||||||
|
out->longitude = sqlite3_column_double(st, 6);
|
||||||
|
out->utcOffset = sqlite3_column_double(st, 7);
|
||||||
|
out->exact = exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs one candidate against a prepared statement. Returns true on a hit.
|
||||||
|
static bool try_one(sqlite3_stmt* st, const char* candidate, size_t len, hammy_dxcc_t* out, bool exact) {
|
||||||
|
sqlite3_reset(st);
|
||||||
|
sqlite3_clear_bindings(st);
|
||||||
|
sqlite3_bind_text(st, 1, candidate, (int)len, SQLITE_STATIC);
|
||||||
|
|
||||||
|
bool hit = (sqlite3_step(st) == SQLITE_ROW);
|
||||||
|
if (hit) { fill(st, out, exact); }
|
||||||
|
|
||||||
|
sqlite3_reset(st);
|
||||||
|
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest-first prefix search over one string.
|
||||||
|
static bool search_prefixes(hammy_refdb_t* db, const char* call, hammy_dxcc_t* out) {
|
||||||
|
size_t len = strlen(call);
|
||||||
|
|
||||||
|
for (size_t n = len; n > 0; n--) {
|
||||||
|
if (try_one(db->stPrefix, call, n, out, false)) { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
if (!call[0]) { return false; }
|
||||||
|
|
||||||
|
memset(out, 0, sizeof(*out));
|
||||||
|
|
||||||
|
// 1. Whole-callsign rules win outright. cty.dat carries a couple of thousand
|
||||||
|
// of these for stations that do not follow their entity's prefix pattern.
|
||||||
|
if (try_one(db->stExact, call, strlen(call), out, true))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 2. Portable designators. "DL/W1AW" is Germany, "W1AW/4" is still the US.
|
||||||
|
// The rule of thumb is that the SHORTER side of the slash is the location
|
||||||
|
// indicator, and a purely numeric tail is a call-area change rather than
|
||||||
|
// an entity change.
|
||||||
|
//
|
||||||
|
// This is a heuristic, not a specification - real callsign parsing has
|
||||||
|
// genuine ambiguities and every logging program handles them slightly
|
||||||
|
// differently. Good enough for a lookup command; revisit before using it
|
||||||
|
// to award DXCC credit. TODO
|
||||||
|
const char* slash = strchr(call, '/');
|
||||||
|
if (slash) {
|
||||||
|
char left[HAMMY_CALLSIGN_MAX] = {0};
|
||||||
|
char right[HAMMY_CALLSIGN_MAX] = {0};
|
||||||
|
|
||||||
|
size_t llen = (size_t)(slash - call);
|
||||||
|
snprintf(left, sizeof(left), "%.*s", (int)llen, call);
|
||||||
|
snprintf(right, sizeof(right), "%s", slash + 1);
|
||||||
|
|
||||||
|
bool right_numeric = true;
|
||||||
|
for (const char* p = right; *p; p++) {
|
||||||
|
if (*p < '0' || *p > '9') {
|
||||||
|
right_numeric = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A numeric tail keeps the home entity: try the left side only.
|
||||||
|
if (right_numeric && right[0]) { return search_prefixes(db, left, out); }
|
||||||
|
|
||||||
|
// Otherwise the shorter side is the location indicator.
|
||||||
|
const char* location = (strlen(right) && strlen(right) < llen) ? right : left;
|
||||||
|
const char* fallback = (location == right) ? left : right;
|
||||||
|
|
||||||
|
if (search_prefixes(db, location, out)) { return true; }
|
||||||
|
|
||||||
|
return search_prefixes(db, fallback, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Plain callsign: longest prefix wins.
|
||||||
|
return search_prefixes(db, call, 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.
|
||||||
|
if ((unsigned char)c & 0x80u) { return false; }
|
||||||
|
|
||||||
|
// Run the query with the character as a string. SQLite's UPPER() handles case-insensitivity.
|
||||||
|
// SQLITE_STATIC is safe because the statement is reset before this returns,
|
||||||
|
// so key never outlives the binding that points at it.
|
||||||
|
char key[2] = { c, '\0' };
|
||||||
|
|
||||||
|
sqlite3_reset(db->stMorse);
|
||||||
|
sqlite3_clear_bindings(db->stMorse);
|
||||||
|
sqlite3_bind_text(db->stMorse, 1, key, 1, SQLITE_STATIC);
|
||||||
|
|
||||||
|
bool hit = false;
|
||||||
|
|
||||||
|
if (sqlite3_step(db->stMorse) == SQLITE_ROW) {
|
||||||
|
const unsigned char* code = sqlite3_column_text(db->stMorse, 0);
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
// Copied rather than handed back directly: the column pointer dies
|
||||||
|
// at the reset below, and the caller has no way to know that.
|
||||||
|
snprintf(db->morseCode, sizeof(db->morseCode), "%s", (const char*)code);
|
||||||
|
*out = db->morseCode;
|
||||||
|
hit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_reset(db->stMorse);
|
||||||
|
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hammy_refdb_get_phonetic(hammy_refdb_t* db, char c, const char** out, const char** outPronunciation) {
|
||||||
|
if (!db || !out) { return false; }
|
||||||
|
|
||||||
|
sqlite3_stmt* const needed[] = { db->stPhonetic };
|
||||||
|
if (!stmts_ready("phonetic", 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.
|
||||||
|
if ((unsigned char)c & 0x80u) { return false; }
|
||||||
|
|
||||||
|
// Run the query with the character as a string. SQLite's UPPER() handles case-insensitivity.
|
||||||
|
// SQLITE_STATIC is safe because the statement is reset before this returns,
|
||||||
|
// so key never outlives the binding that points at it.
|
||||||
|
char key[2] = { c, '\0' };
|
||||||
|
|
||||||
|
sqlite3_reset(db->stPhonetic);
|
||||||
|
sqlite3_clear_bindings(db->stPhonetic);
|
||||||
|
sqlite3_bind_text(db->stPhonetic, 1, key, 1, SQLITE_STATIC);
|
||||||
|
|
||||||
|
bool hit = false;
|
||||||
|
|
||||||
|
if (sqlite3_step(db->stPhonetic) == SQLITE_ROW) {
|
||||||
|
if (sqlite3_column_count(db->stPhonetic) < 2) {
|
||||||
|
// Error
|
||||||
|
sqlite3_reset(db->stPhonetic);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsigned char* code = sqlite3_column_text(db->stPhonetic, 0);
|
||||||
|
const unsigned char* codePronunciation = sqlite3_column_text(db->stPhonetic, 1);
|
||||||
|
|
||||||
|
if (code && codePronunciation) {
|
||||||
|
// Copied rather than handed back directly: the column pointer dies
|
||||||
|
// at the reset below, and the caller has no way to know that.
|
||||||
|
snprintf(db->phoneticCode, sizeof(db->phoneticCode), "%s", (const char*)code);
|
||||||
|
*out = db->phoneticCode;
|
||||||
|
snprintf(db->phoneticCodePronunciation, sizeof(db->phoneticCodePronunciation), "%s", (const char*)codePronunciation);
|
||||||
|
*outPronunciation = db->phoneticCodePronunciation;
|
||||||
|
hit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_reset(db->stPhonetic);
|
||||||
|
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hammy_refdb_get_qcode(hammy_refdb_t* db, const char* code, const char** outQuestion, const char** outAnswer) {
|
||||||
|
if (!db || !code || !outQuestion || !outAnswer) { return false; }
|
||||||
|
|
||||||
|
sqlite3_stmt* const needed[] = { db->stQCode };
|
||||||
|
if (!stmts_ready("qcodes", needed, 1)) { return false; }
|
||||||
|
|
||||||
|
// Read the get_morse comment, I ain't writing this again (entire string edition)
|
||||||
|
for (const char* p = code; *p != '\0'; p++) {
|
||||||
|
if ((unsigned char)*p & 0x80u) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query the string
|
||||||
|
sqlite3_reset(db->stQCode);
|
||||||
|
sqlite3_clear_bindings(db->stQCode);
|
||||||
|
sqlite3_bind_text(db->stQCode, 1, code, -1, SQLITE_TRANSIENT); // -1 tells SQLite to figure out the length itself (strlen() call - requires NULL term)
|
||||||
|
|
||||||
|
bool hit = false;
|
||||||
|
|
||||||
|
if (sqlite3_step(db->stQCode) == SQLITE_ROW) {
|
||||||
|
if (sqlite3_column_count(db->stQCode) < 2) { // Error
|
||||||
|
sqlite3_reset(db->stQCode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsigned char* questionStr = sqlite3_column_text(db->stQCode, 0);
|
||||||
|
const unsigned char* answerStr = sqlite3_column_text(db->stQCode, 1);
|
||||||
|
|
||||||
|
if (questionStr) {
|
||||||
|
snprintf(db->qcodeQuestion, sizeof(db->qcodeQuestion), "%s", (const char*)questionStr);
|
||||||
|
*outQuestion = db->qcodeQuestion;
|
||||||
|
hit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answerStr) {
|
||||||
|
snprintf(db->qcodeAnswer, sizeof(db->qcodeAnswer), "%s", (const char*)answerStr);
|
||||||
|
*outAnswer = db->qcodeAnswer;
|
||||||
|
hit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_reset(db->stQCode);
|
||||||
|
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hammy_refdb_get_abbr(hammy_refdb_t* db, const char* code, const char** outStr, const char** outContext) {
|
||||||
|
if (!db || !code || !outStr || !outContext) { return false; }
|
||||||
|
|
||||||
|
sqlite3_stmt* const needed[] = { db->stAbbr };
|
||||||
|
if (!stmts_ready("qcodes", needed, 1)) { return false; }
|
||||||
|
|
||||||
|
// Read the get_morse comment, I ain't writing this again (entire string edition)
|
||||||
|
for (const char* p = code; *p != '\0'; p++) {
|
||||||
|
if ((unsigned char)*p & 0x80u) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query the string
|
||||||
|
sqlite3_reset(db->stAbbr);
|
||||||
|
sqlite3_clear_bindings(db->stAbbr);
|
||||||
|
sqlite3_bind_text(db->stAbbr, 1, code, -1, SQLITE_TRANSIENT); // -1 tells SQLite to figure out the length itself (strlen() call - requires NULL term)
|
||||||
|
|
||||||
|
bool hit = false;
|
||||||
|
|
||||||
|
if (sqlite3_step(db->stAbbr) == SQLITE_ROW) {
|
||||||
|
if (sqlite3_column_count(db->stAbbr) < 2) { // Error
|
||||||
|
sqlite3_reset(db->stAbbr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsigned char* meaningStr = sqlite3_column_text(db->stAbbr, 0);
|
||||||
|
const unsigned char* ctxStr = sqlite3_column_text(db->stAbbr, 1);
|
||||||
|
|
||||||
|
if (meaningStr) {
|
||||||
|
snprintf(db->abbrStr, sizeof(db->abbrStr), "%s", (const char*)meaningStr);
|
||||||
|
*outStr = db->abbrStr;
|
||||||
|
hit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctxStr) {
|
||||||
|
snprintf(db->abbrCtx, sizeof(db->abbrCtx), "%s", (const char*)ctxStr);
|
||||||
|
*outContext = db->abbrCtx;
|
||||||
|
hit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_reset(db->stAbbr);
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 centerd 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 || !db->stCountryList) { 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#include <hammy/utils.h>
|
||||||
|
|
||||||
|
void hammy_to_uppercase(char* str) {
|
||||||
|
for (size_t i = 0; str[i] != '\0'; i++) {
|
||||||
|
str[i] = (char)toupper((unsigned char)str[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void hammy_to_lowercase(char* str) {
|
||||||
|
for (size_t i = 0; str[i] != '\0'; i++) {
|
||||||
|
str[i] = (char)tolower((unsigned char)str[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-7
@@ -1,4 +1,5 @@
|
|||||||
#include <concord/discord.h>
|
#include <concord/discord.h>
|
||||||
|
#include <concord/discord-internal.h>
|
||||||
#include <concord/log.h>
|
#include <concord/log.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
|
||||||
@@ -6,6 +7,20 @@
|
|||||||
#include <hammy/pool.h>
|
#include <hammy/pool.h>
|
||||||
#include <hammy/worker.h>
|
#include <hammy/worker.h>
|
||||||
|
|
||||||
|
// Workaround for a concord 3.0.1 bug. _discord_clone_gateway_cleanup()
|
||||||
|
// (discord-client.c:833) frees payload.json.table and then frees payload.data
|
||||||
|
// as well, but payload.data is a jsmnf_pair pointing INTO that table, not a
|
||||||
|
// separate allocation. Every discord_cleanup() of a clone is therefore an
|
||||||
|
// invalid free. Clearing the field first skips that free; the table it points
|
||||||
|
// into is still released, so nothing leaks.
|
||||||
|
static void hammy_worker_cleanup_clone(struct discord* clone) {
|
||||||
|
if (!clone) { return; }
|
||||||
|
|
||||||
|
clone->gw.payload.data = NULL;
|
||||||
|
|
||||||
|
discord_cleanup(clone);
|
||||||
|
}
|
||||||
|
|
||||||
static void* hammy_worker_main(void* arg) {
|
static void* hammy_worker_main(void* arg) {
|
||||||
hammy_worker_t* worker = (hammy_worker_t*)arg;
|
hammy_worker_t* worker = (hammy_worker_t*)arg;
|
||||||
hammy_pool_t* pool = worker->pool;
|
hammy_pool_t* pool = worker->pool;
|
||||||
@@ -33,14 +48,14 @@ static void* hammy_worker_main(void* arg) {
|
|||||||
pthread_mutex_unlock(&pool->lock);
|
pthread_mutex_unlock(&pool->lock);
|
||||||
|
|
||||||
// From here, the worker owns the job and is responsible for destroying it.
|
// From here, the worker owns the job and is responsible for destroying it.
|
||||||
log_info("[worker %d] Processing job %llu from user %llu", worker->id, job->id, job->user);
|
log_info("[worker %d] Processing job %lu from user %lu", worker->id, job->id, job->user);
|
||||||
int64_t age = hammy_job_age_ms(job, worker->clientCopy);
|
int64_t age = hammy_job_age_ms(job, worker->clientCopy);
|
||||||
|
|
||||||
if (age > HAMMY_JOB_MAX_AGE_MS) {
|
if (age > HAMMY_JOB_MAX_AGE_MS) {
|
||||||
log_warn("[worker %d] Dropping stale job '%s' (age %lld ms)", worker->id, job->command ? job->command : "unknown", (long long)age);
|
log_warn("[worker %d] Dropping stale job '%s' (age %lld ms)", worker->id, job->command ? job->command : "unknown", (long long)age);
|
||||||
hammy_job_reply(job, worker->clientCopy, "Sorry, your command took too long to process and was dropped. Please try again.");
|
hammy_job_reply(job, worker->clientCopy, "Command Timeout", "Sorry, your command took too long to process and was dropped. Please try again.", true);
|
||||||
} else {
|
} else {
|
||||||
hammy_job_run(job, worker->clientCopy);
|
hammy_job_run(job, worker->clientCopy, worker->refdb);
|
||||||
}
|
}
|
||||||
|
|
||||||
hammy_job_destroy(&job);
|
hammy_job_destroy(&job);
|
||||||
@@ -63,17 +78,26 @@ bool hammy_worker_start(hammy_worker_t* worker, hammy_pool_t* pool, struct disco
|
|||||||
worker->clientCopy = NULL;
|
worker->clientCopy = NULL;
|
||||||
worker->started = false;
|
worker->started = false;
|
||||||
|
|
||||||
// According to the concord spec, each thread must have its own discord client, so we clone it here.
|
// Each thread needs its own client. The queues, pollers and timers are all
|
||||||
// However, concord's buffers, URLs, headers, etc. are NOT shared-safe. They're per-client.
|
// shared through pointers, but client->registry - the reflect-c registry
|
||||||
|
// that serialises request bodies on the calling thread - is not.
|
||||||
|
// discord_clone() copies the gateway's *current* payload, so this only
|
||||||
|
// works while a dispatch is in flight. See hammy_pool_create().
|
||||||
worker->clientCopy = discord_clone(client);
|
worker->clientCopy = discord_clone(client);
|
||||||
if (!worker->clientCopy) {
|
if (!worker->clientCopy) {
|
||||||
log_error("[worker %d] Failed to clone client", id);
|
log_error("[worker %d] Failed to clone client", id);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open a reference to sqlite
|
||||||
|
worker->refdb = hammy_refdb_open("hammy-ref.sqlite"); // TODO: Probably don't hardcode this?
|
||||||
|
if (!worker->refdb) {
|
||||||
|
log_error("[worker %d] Failed to open ref to sqlite. Commands requiring it will be unavailable!", id); // TODO: Consider making this a hard-fail
|
||||||
|
}
|
||||||
|
|
||||||
if (pthread_create(&worker->thread, NULL, &hammy_worker_main, worker) != 0) {
|
if (pthread_create(&worker->thread, NULL, &hammy_worker_main, worker) != 0) {
|
||||||
log_error("[worker %d] Failed to create thread", id);
|
log_error("[worker %d] Failed to create thread", id);
|
||||||
discord_cleanup(worker->clientCopy);
|
hammy_worker_cleanup_clone(worker->clientCopy);
|
||||||
worker->clientCopy = NULL;
|
worker->clientCopy = NULL;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -93,7 +117,11 @@ void hammy_worker_join(hammy_worker_t* worker) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (worker->clientCopy) {
|
if (worker->clientCopy) {
|
||||||
discord_cleanup(worker->clientCopy);
|
hammy_worker_cleanup_clone(worker->clientCopy);
|
||||||
worker->clientCopy = NULL;
|
worker->clientCopy = NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (worker->refdb) {
|
||||||
|
hammy_refdb_close(&worker->refdb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-21
@@ -1,44 +1,92 @@
|
|||||||
#include <string.h>
|
#include <concord/discord.h>
|
||||||
#include <signal.h>
|
|
||||||
#include <concord/log.h>
|
#include <concord/log.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
#include <hammy/bot.h>
|
#include <hammy/bot.h>
|
||||||
|
#include <hammy/pool.h>
|
||||||
|
|
||||||
static void on_signal(int sig) {
|
#define HAMMY_CONFIG_PATH "config.json"
|
||||||
|
|
||||||
|
static hammy_bot_t* g_bot = NULL;
|
||||||
|
|
||||||
|
static void hammy_on_sigint(int sig) {
|
||||||
(void)sig;
|
(void)sig;
|
||||||
discord_shutdown_all();
|
|
||||||
|
// Only sets a flag inside Concord and makes discord_run() return. The real
|
||||||
|
// teardown happens back in main().
|
||||||
|
if (g_bot && g_bot->client) discord_shutdown(g_bot->client);
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_ready(struct discord* client, const struct discord_ready* event) {
|
static void hammy_on_ready(struct discord* client, const struct discord_ready* event) {
|
||||||
(void)client;
|
hammy_bot_t* bot = (hammy_bot_t*)discord_get_data(client);
|
||||||
log_info("[master] Logged in as %s", event->user->username);
|
if (!bot) return;
|
||||||
|
|
||||||
|
log_info("[hammy] logged in as %s", event->user->username);
|
||||||
|
|
||||||
|
// The application id is only available now, and both registration and
|
||||||
|
// response editing need it.
|
||||||
|
bot->appId = event->application->id;
|
||||||
|
|
||||||
|
//hammy_bot_deregister_all_commands(bot);
|
||||||
|
|
||||||
|
// discord_clone() deep-copies the gateway's *current* payload, so it only
|
||||||
|
// succeeds from inside a dispatch callback. READY is the first one we get.
|
||||||
|
if (!hammy_bot_start_pool(bot, 0, 0)) { // 0, 0 = defaults
|
||||||
|
log_fatal("[hammy] Could not start the worker pool. Shutting down.");
|
||||||
|
discord_shutdown(client);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hammy_bot_register_commands(bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
struct sigaction sa = { 0 };
|
|
||||||
sa.sa_handler = &on_signal;
|
|
||||||
sigemptyset(&sa.sa_mask);
|
|
||||||
sa.sa_flags = 0; /* deliberately no SA_RESTART */
|
|
||||||
|
|
||||||
sigaction(SIGTERM, &sa, NULL);
|
|
||||||
sigaction(SIGINT, &sa, NULL);
|
|
||||||
|
|
||||||
ccord_global_init();
|
ccord_global_init();
|
||||||
|
|
||||||
hammy_bot_t* bot = hammy_bot_create();
|
hammy_bot_t* bot = hammy_bot_create();
|
||||||
if (!bot) {
|
if (!bot) {
|
||||||
log_error("[master] Hammy Bot creation returned NULL! Bailing!");
|
fprintf(stderr, "could not create bot\n");
|
||||||
ccord_global_cleanup();
|
ccord_global_cleanup();
|
||||||
|
|
||||||
return 1;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
hammy_bot_set_on_ready(bot, &on_ready);
|
// Token and logging settings both come out of config.json.
|
||||||
hammy_bot_run(bot); // This will block and exit afterwards
|
bot->client = discord_config_init(HAMMY_CONFIG_PATH);
|
||||||
|
if (!bot->client) {
|
||||||
|
fprintf(stderr, "could not init discord client from %s\n", HAMMY_CONFIG_PATH);
|
||||||
|
hammy_bot_destroy(&bot);
|
||||||
|
ccord_global_cleanup();
|
||||||
|
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = false; // Declared before the gotos so they don't jump over it.
|
||||||
|
|
||||||
|
g_bot = bot;
|
||||||
|
signal(SIGINT, &hammy_on_sigint);
|
||||||
|
signal(SIGTERM, &hammy_on_sigint);
|
||||||
|
|
||||||
|
// Order matters: commands loaded before the ready callback can register
|
||||||
|
// them. The pool starts from on_ready, not here - see the comment there.
|
||||||
|
if (!hammy_bot_load_builtins(bot)) goto fail;
|
||||||
|
if (!hammy_bot_set_on_ready(bot, &hammy_on_ready)) goto fail;
|
||||||
|
|
||||||
|
// Blocks until shutdown. A NULL pool afterwards means on_ready bailed out
|
||||||
|
// before it could start one.
|
||||||
|
ok = hammy_bot_run(bot) && bot->pool != NULL;
|
||||||
|
|
||||||
hammy_bot_destroy(&bot);
|
hammy_bot_destroy(&bot);
|
||||||
|
|
||||||
ccord_global_cleanup();
|
ccord_global_cleanup();
|
||||||
|
|
||||||
return 0;
|
return ok ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
|
||||||
|
fail:
|
||||||
|
hammy_bot_destroy(&bot);
|
||||||
|
ccord_global_cleanup();
|
||||||
|
|
||||||
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-26
@@ -1,26 +0,0 @@
|
|||||||
#include "custom.PRE.h"
|
|
||||||
|
|
||||||
#include "application.PRE.h"
|
|
||||||
#include "audit_log.PRE.h"
|
|
||||||
#include "auto_moderation.PRE.h"
|
|
||||||
#include "invite.PRE.h"
|
|
||||||
#include "channel.PRE.h"
|
|
||||||
#include "emoji.PRE.h"
|
|
||||||
#include "guild.PRE.h"
|
|
||||||
#include "guild_scheduled_event.PRE.h"
|
|
||||||
#include "guild_template.PRE.h"
|
|
||||||
#include "stage_instance.PRE.h"
|
|
||||||
#include "sticker.PRE.h"
|
|
||||||
#include "user.PRE.h"
|
|
||||||
#include "voice.PRE.h"
|
|
||||||
#include "webhook.PRE.h"
|
|
||||||
|
|
||||||
#include "gateway.PRE.h"
|
|
||||||
#include "oauth2.PRE.h"
|
|
||||||
#include "permissions.PRE.h"
|
|
||||||
#include "teams.PRE.h"
|
|
||||||
#include "voice_connections.PRE.h"
|
|
||||||
|
|
||||||
#include "application_commands.PRE.h"
|
|
||||||
#include "message_components.PRE.h"
|
|
||||||
#include "interactions.PRE.h"
|
|
||||||
+53
-33
@@ -31,59 +31,75 @@ extern "C" {
|
|||||||
#include "priority_queue.h"
|
#include "priority_queue.h"
|
||||||
#include "attributes.h"
|
#include "attributes.h"
|
||||||
|
|
||||||
/** @brief Return 1 if string isn't considered empty */
|
|
||||||
#define NOT_EMPTY_STR(str) ((str) && *(str))
|
|
||||||
/**
|
/**
|
||||||
* @brief Get container `type` from a field `ptr`
|
* @brief return 1 if string isn't empty
|
||||||
*
|
*
|
||||||
* @param[in] ptr the field contained in `type`
|
* @param[in] _str the string to check
|
||||||
* @param[in] type the container datatype
|
|
||||||
* @param[in] path the path to the field from the container POV
|
|
||||||
*/
|
*/
|
||||||
#define CONTAINEROF(ptr, type, path) \
|
#define NOT_EMPTY_STR(_str) ((_str) && *(_str))
|
||||||
((type *)((char *)(ptr) - offsetof(type, path)))
|
/**
|
||||||
|
* @brief get container `type` from a field `ptr`
|
||||||
|
*
|
||||||
|
* @param[in] _ptr the field contained in `type`
|
||||||
|
* @param[in] _type the container datatype
|
||||||
|
* @param[in] _path the path to the field from the container POV
|
||||||
|
*/
|
||||||
|
#define CONTAINEROF(_ptr, _type, _path) \
|
||||||
|
((_type *)((char *)(_ptr) - offsetof(_type, _path)))
|
||||||
|
|
||||||
/** @defgroup DiscordInternal Internal implementation details
|
/** @defgroup DiscordInternal Internal implementation details
|
||||||
* @brief Documentation useful when developing or debugging Concord itself
|
* @brief Documentation useful when developing or debugging Concord itself
|
||||||
* @{ */
|
* @{ */
|
||||||
|
|
||||||
/** @brief cet client from its nested field */
|
/**
|
||||||
#define CLIENT(ptr, path) CONTAINEROF(ptr, struct discord, path)
|
* @brief get client from its nested field
|
||||||
|
*
|
||||||
|
* @param[in] _ptr the nested field pointer
|
||||||
|
* @param[in] _path the path to the discord client from the nested field POV
|
||||||
|
*/
|
||||||
|
#define CLIENT(_ptr, _path) CONTAINEROF(_ptr, struct discord, _path)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief log and return `code` if `expect` condition is false
|
* @brief log and return `code` if `expect` condition is false
|
||||||
*
|
*
|
||||||
* @param[in] client the Discord client
|
* @param[in] _client the Discord client
|
||||||
* @param[in] expect the expected outcome
|
* @param[in] _expect the expected outcome
|
||||||
* @param[in] code return CCORDcode error code
|
* @param[in] _code return CCORDcode error code
|
||||||
* @param[in] reason for return
|
* @param[in] _reason for return
|
||||||
* @return the provided @ref CCORDcode `code` parameter
|
* @return the provided @ref CCORDcode `code` parameter
|
||||||
*/
|
*/
|
||||||
#define CCORD_EXPECT(client, expect, code, reason) \
|
#define CCORD_EXPECT(_client, _expect, _code, _reason) \
|
||||||
do { \
|
do { \
|
||||||
if (!(expect)) { \
|
if (!(_expect)) { \
|
||||||
logmod_log(ERROR, (client)->logger, \
|
logmod_log(ERROR, (_client)->logger, \
|
||||||
"Expected: " #expect " | %s (%s)", \
|
"Expected: " #_expect " | %s (%s)", \
|
||||||
discord_strerror(code, client), \
|
discord_strerror(_code, _client), \
|
||||||
discord_code_as_string(code)); \
|
discord_code_as_string(_code)); \
|
||||||
return code; \
|
return _code; \
|
||||||
} \
|
} \
|
||||||
} while (0)
|
} while (0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief log and return `code` if function call doesn't returns @ref CCORD_OK
|
* @brief log and return `code` if function call doesn't returns @ref CCORD_OK
|
||||||
*
|
*
|
||||||
* @param[in] client the Discord client
|
* @param[in] _client the Discord client
|
||||||
* @param[in] fn the function call that returns CCORDcode
|
* @param[in] _type the datatype to be converted to JSON
|
||||||
|
* @param[out] _body the JSON body buffer
|
||||||
|
* @param[in] _params the parameters to be converted to JSON
|
||||||
* @return the returned @ref CCORDcode `code` parameter
|
* @return the returned @ref CCORDcode `code` parameter
|
||||||
*/
|
*/
|
||||||
#define CCORD_EXPECT_OK(client, fn) \
|
#define CCORD_DATA_TO_JSON(_client, _type, _body, _params) \
|
||||||
do { \
|
do { \
|
||||||
const CCORDcode code = (fn); \
|
struct reflectc_wrap *w_params = \
|
||||||
|
reflectc_from_##_type((_client)->registry, _params, NULL); \
|
||||||
|
CCORDcode code = discord_data_wrap_to_json(w_params, &(_body)->start, \
|
||||||
|
&(_body)->size); \
|
||||||
|
reflectc_cleanup((_client)->registry, w_params); \
|
||||||
if (code != CCORD_OK) { \
|
if (code != CCORD_OK) { \
|
||||||
logmod_log(ERROR, (client)->logger, \
|
logmod_log(ERROR, (_client)->logger, \
|
||||||
"Expected: CCORD_OK == " #fn " | %s (%s)", \
|
"Expected: CCORD_OK == discord_data_wrap_to_json | " \
|
||||||
discord_strerror(code, client), \
|
"%s (%s)", \
|
||||||
|
discord_strerror(code, _client), \
|
||||||
discord_code_as_string(code)); \
|
discord_code_as_string(code)); \
|
||||||
return code; \
|
return code; \
|
||||||
} \
|
} \
|
||||||
@@ -421,11 +437,9 @@ struct discord_ret_response {
|
|||||||
/** size of datatype in bytes */
|
/** size of datatype in bytes */
|
||||||
size_t size;
|
size_t size;
|
||||||
/** initializer function for datatype fields */
|
/** initializer function for datatype fields */
|
||||||
void (*init)(void *data);
|
struct reflectc_wrap *(*init)(struct reflectc *registry,
|
||||||
/** populate datatype with JSON values */
|
void *data,
|
||||||
size_t (*from_json)(const char *json, size_t len, void *data);
|
struct reflectc_wrap *root);
|
||||||
/** cleanup function for datatype */
|
|
||||||
void (*cleanup)(void *data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1191,6 +1205,12 @@ struct discord_cache {
|
|||||||
* @see discord_init(), discord_config_init(), discord_cleanup()
|
* @see discord_init(), discord_config_init(), discord_cleanup()
|
||||||
*/
|
*/
|
||||||
struct discord {
|
struct discord {
|
||||||
|
/**
|
||||||
|
* the registry for all wrapped discord data types
|
||||||
|
* @note keep as first member for casting to `struct reflectc`
|
||||||
|
* @see DiscordDataWrap
|
||||||
|
*/
|
||||||
|
struct reflectc *registry;
|
||||||
/** `DISCORD` logging module */
|
/** `DISCORD` logging module */
|
||||||
struct logmod_logger *logger;
|
struct logmod_logger *logger;
|
||||||
/** LogMod loggers table */
|
/** LogMod loggers table */
|
||||||
|
|||||||
+30
-49
@@ -12,78 +12,59 @@
|
|||||||
typedef void (*cast_done_typed)(struct discord *,
|
typedef void (*cast_done_typed)(struct discord *,
|
||||||
struct discord_response *,
|
struct discord_response *,
|
||||||
const void *);
|
const void *);
|
||||||
typedef void (*cast_init)(void *);
|
typedef struct reflectc_wrap *(*cast_init)(struct reflectc *,
|
||||||
typedef void (*cast_cleanup)(void *);
|
void *,
|
||||||
typedef size_t (*cast_from_json)(const char *, size_t, void *);
|
struct reflectc_wrap *);
|
||||||
|
|
||||||
/* helper typedef for getting sizeof of `struct discord_ret` common fields */
|
/* helper typedef for getting sizeof of `struct discord_ret` common fields */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
DISCORD_RET_DEFAULT_FIELDS;
|
DISCORD_RET_DEFAULT_FIELDS;
|
||||||
} discord_ret_default_fields;
|
} discord_ret_default_fields;
|
||||||
|
|
||||||
#define _RET_COPY_TYPED(dest, src) \
|
#define _RET_COPY_TYPED(_dest, _src) \
|
||||||
do { \
|
do { \
|
||||||
memcpy(&(dest), &(src), sizeof(discord_ret_default_fields)); \
|
memcpy(&(_dest), &(_src), sizeof(discord_ret_default_fields)); \
|
||||||
(dest).has_type = true; \
|
(_dest).has_type = true; \
|
||||||
(dest).done.typed = (cast_done_typed)(src).done; \
|
(_dest).done.typed = (cast_done_typed)(_src).done; \
|
||||||
(dest).sync = (src).sync; \
|
(_dest).sync = (_src).sync; \
|
||||||
} while (0)
|
} while (0)
|
||||||
|
|
||||||
#define _RET_COPY_TYPELESS(dest, src) \
|
#define _RET_COPY_TYPELESS(_dest, _src) \
|
||||||
do { \
|
do { \
|
||||||
memcpy(&(dest), &(src), sizeof(discord_ret_default_fields)); \
|
memcpy(&(_dest), &(_src), sizeof(discord_ret_default_fields)); \
|
||||||
(dest).has_type = false; \
|
(_dest).has_type = false; \
|
||||||
(dest).done.typeless = (src).done; \
|
(_dest).done.typeless = (_src).done; \
|
||||||
(dest).sync = (void *)(src).sync; \
|
(_dest).sync = (void *)(_src).sync; \
|
||||||
} while (0)
|
} while (0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Helper for setting attributes for a specs-generated return struct
|
* @brief Helper for setting attributes for a specs-generated return struct
|
||||||
*
|
*
|
||||||
* @param[out] attr @ref discord_attributes handler to be initialized
|
* @param[out] _attr @ref discord_attributes handler to be initialized
|
||||||
* @param[in] type datatype of the struct
|
* @param[in] _type datatype of the struct
|
||||||
* @param[in] ret dispatch attributes
|
* @param[in] _ret dispatch attributes
|
||||||
* @param[in] _reason reason for request (if available)
|
* @param[in] _reason reason for request (if available)
|
||||||
*/
|
*/
|
||||||
#define DISCORD_ATTR_INIT(attr, type, ret, _reason) \
|
#define DISCORD_ATTR_INIT(_attr, _type, _ret, _reason) \
|
||||||
do { \
|
do { \
|
||||||
(attr).response.size = sizeof(struct type); \
|
(_attr).response.size = sizeof(struct _type); \
|
||||||
(attr).response.init = (cast_init)type##_init; \
|
(_attr).response.init = (cast_init)reflectc_from_##_type; \
|
||||||
(attr).response.from_json = (cast_from_json)type##_from_json; \
|
(_attr).reason = _reason; \
|
||||||
(attr).response.cleanup = (cast_cleanup)type##_cleanup; \
|
if (_ret) _RET_COPY_TYPED((_attr).dispatch, *(_ret)); \
|
||||||
(attr).reason = _reason; \
|
|
||||||
if (ret) _RET_COPY_TYPED(attr.dispatch, *ret); \
|
|
||||||
} while (0)
|
} while (0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Helper for setting attributes for a specs-generated list
|
* @brief Helper for setting attributes for attributes that don't have a
|
||||||
*
|
|
||||||
* @param[out] attr @ref discord_attributes handler to be initialized
|
|
||||||
* @param[in] type datatype of the list
|
|
||||||
* @param[in] ret dispatch attributes
|
|
||||||
* @param[in] _reason reason for request (if available)
|
|
||||||
*/
|
|
||||||
#define DISCORD_ATTR_LIST_INIT(attr, type, ret, _reason) \
|
|
||||||
do { \
|
|
||||||
(attr).response.size = sizeof(struct type); \
|
|
||||||
(attr).response.from_json = (cast_from_json)type##_from_json; \
|
|
||||||
(attr).response.cleanup = (cast_cleanup)type##_cleanup; \
|
|
||||||
(attr).reason = _reason; \
|
|
||||||
if (ret) _RET_COPY_TYPED(attr.dispatch, *ret); \
|
|
||||||
} while (0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Helper for setting attributes for attruests that doensn't expect a
|
|
||||||
* response object
|
* response object
|
||||||
*
|
*
|
||||||
* @param[out] attr @ref discord_attributes handler to be initialized
|
* @param[out] _attr @ref discord_attributes handler to be initialized
|
||||||
* @param[in] ret dispatch attributes
|
* @param[in] _ret dispatch attributes
|
||||||
* @param[in] _reason reason for request (if available)
|
* @param[in] _reason reason for request (if available)
|
||||||
*/
|
*/
|
||||||
#define DISCORD_ATTR_BLANK_INIT(attr, ret, _reason) \
|
#define DISCORD_ATTR_BLANK_INIT(_attr, _ret, _reason) \
|
||||||
do { \
|
do { \
|
||||||
(attr).reason = _reason; \
|
(_attr).reason = _reason; \
|
||||||
if (ret) _RET_COPY_TYPELESS(attr.dispatch, *ret); \
|
if (_ret) _RET_COPY_TYPELESS((_attr).dispatch, *(_ret)); \
|
||||||
} while (0)
|
} while (0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,10 +72,10 @@ typedef struct {
|
|||||||
*
|
*
|
||||||
* @param[in,out] attchs a @ref discord_attachments to have its IDs initialized
|
* @param[in,out] attchs a @ref discord_attachments to have its IDs initialized
|
||||||
*/
|
*/
|
||||||
#define DISCORD_ATTACHMENTS_IDS_INIT(attchs) \
|
#define DISCORD_ATTACHMENTS_IDS_INIT(_attchs) \
|
||||||
do { \
|
do { \
|
||||||
for (int i = 0; i < attchs->size; ++i) { \
|
for (int i = 0; i < (_attchs)->size; ++i) { \
|
||||||
attchs->array[i].id = (u64snowflake)i; \
|
(_attchs)->array[i].id = (u64snowflake)i; \
|
||||||
} \
|
} \
|
||||||
} while (0)
|
} while (0)
|
||||||
|
|
||||||
|
|||||||
Vendored
+215
@@ -37,8 +37,17 @@ extern "C" {
|
|||||||
|
|
||||||
/* forward declaration */
|
/* forward declaration */
|
||||||
struct discord;
|
struct discord;
|
||||||
|
struct reflectc;
|
||||||
/**/
|
/**/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the reflectc registry from a Discord client
|
||||||
|
*
|
||||||
|
* @param client the client created with discord_from_json() or discord_from_token()
|
||||||
|
* @return the reflectc registry used by the client
|
||||||
|
*/
|
||||||
|
struct reflectc *discord_get_registry(struct discord *client);
|
||||||
|
|
||||||
#include "discord_codecs.h"
|
#include "discord_codecs.h"
|
||||||
#include "discord-response.h"
|
#include "discord-response.h"
|
||||||
|
|
||||||
@@ -209,6 +218,12 @@ void discord_shutdown_all(void);
|
|||||||
*/
|
*/
|
||||||
bool discord_shutdown_all_ongoing(void);
|
bool discord_shutdown_all_ongoing(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Backwards compatible alias for discord_shutdown_all()
|
||||||
|
* @deprecated since v3.0.0
|
||||||
|
*/
|
||||||
|
#define ccord_shutdown_async() discord_shutdown_all()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Creates a Discord Client handle from a token
|
* @brief Creates a Discord Client handle from a token
|
||||||
* @see discord_get_logmod() to configure logging behavior
|
* @see discord_get_logmod() to configure logging behavior
|
||||||
@@ -235,6 +250,15 @@ struct discord *discord_from_json(const char config_file[]);
|
|||||||
struct discord_config {
|
struct discord_config {
|
||||||
/** the bot token */
|
/** the bot token */
|
||||||
char *token;
|
char *token;
|
||||||
|
/**
|
||||||
|
* optional override for the REST API base URL
|
||||||
|
* (e.g. `"http://127.0.0.1:8080"` for a local test server)
|
||||||
|
* @note when `NULL` (the default) @ref DISCORD_API_BASE_URL is used
|
||||||
|
* @note only read during client initialization and copied by the
|
||||||
|
* User-Agent layer; the client does not take ownership and will
|
||||||
|
* never free this pointer
|
||||||
|
*/
|
||||||
|
char *base_url;
|
||||||
struct {
|
struct {
|
||||||
/** minimum logging level */
|
/** minimum logging level */
|
||||||
enum logmod_levels level;
|
enum logmod_levels level;
|
||||||
@@ -427,6 +451,197 @@ struct logmod *discord_get_logmod(struct discord *client);
|
|||||||
*/
|
*/
|
||||||
struct io_poller *discord_get_io_poller(struct discord *client);
|
struct io_poller *discord_get_io_poller(struct discord *client);
|
||||||
|
|
||||||
|
/** @addtogroup DiscordDataWrap Data Wrap
|
||||||
|
* @brief Helpers for wrapping Discord data types for easier management
|
||||||
|
* @{ */
|
||||||
|
|
||||||
|
#define __CAT(_a, _b) _a##_b
|
||||||
|
#define _CAT(_a, _b) __CAT(_a, _b)
|
||||||
|
#define _EXPECT_CONTAINER__struct
|
||||||
|
#define _EXPECT_CONTAINER__union
|
||||||
|
#define _DISCORD_SYMBOL_WITHOUT_CONTAINER(_symbol) \
|
||||||
|
_CAT(_EXPECT_CONTAINER__, _symbol)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The Discord data wrap structure
|
||||||
|
*
|
||||||
|
* This struct is used to wrap Discord data types for easier management
|
||||||
|
*/
|
||||||
|
#define discord_data_wrap reflectc_wrap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Wrap a Discord data type into a reflectc_wrap structure
|
||||||
|
*
|
||||||
|
* @param _symbol the Discord data type symbol (with struct or union)
|
||||||
|
* @param _client the client created with discord_from_token()
|
||||||
|
* @param _data the Discord data type to be wrapped
|
||||||
|
* @return the @ref discord_data_wrap Discord data type
|
||||||
|
*/
|
||||||
|
#define discord_data_wrap_from(_symbol, _client, _data) \
|
||||||
|
_CAT(reflectc_from_, _DISCORD_SYMBOL_WITHOUT_CONTAINER(_symbol))( \
|
||||||
|
discord_get_registry((_client)), _data, NULL)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Cleanup a Discord data type wrapped into a reflectc_wrap structure
|
||||||
|
*
|
||||||
|
* Releases everything discord_data_from_json() allocated for `_data` —
|
||||||
|
* strings, nested objects, list arrays — plus the reflect-c wrap and its
|
||||||
|
* registry entry. Every non-NULL pointer member is treated as owned: do
|
||||||
|
* not call this on hand-built structs pointing at literals or stack
|
||||||
|
* objects.
|
||||||
|
*
|
||||||
|
* @param _client the client created with discord_from_token()
|
||||||
|
* @param _data the Discord data type to be cleaned up
|
||||||
|
*/
|
||||||
|
#define discord_data_cleanup(_client, _data) \
|
||||||
|
discord_data_free(discord_get_registry((_client)), (_data))
|
||||||
|
|
||||||
|
/** Function backing @ref discord_data_cleanup; see its contract */
|
||||||
|
void discord_data_free(struct reflectc *registry, void *data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Drop the wrap cached for `_data` without touching its contents
|
||||||
|
*
|
||||||
|
* discord_data_to_json() caches a reflect-c wrap keyed by `_data`'s
|
||||||
|
* address; release it with this once done encoding a caller-owned
|
||||||
|
* struct (e.g. stack-built params). Without it the stale registry entry
|
||||||
|
* would be wrongly reused by a future object at the same address.
|
||||||
|
* Decoded structs don't need this — discord_data_cleanup() releases
|
||||||
|
* both the data and the wrap.
|
||||||
|
*
|
||||||
|
* @param _client the client created with discord_from_token()
|
||||||
|
* @param _data the encoded Discord data type to drop the wrap for
|
||||||
|
*/
|
||||||
|
#define discord_data_unwrap(_client, _data) \
|
||||||
|
discord_data_release(discord_get_registry((_client)), (_data))
|
||||||
|
|
||||||
|
/** Function backing @ref discord_data_unwrap */
|
||||||
|
void discord_data_release(struct reflectc *registry, void *data);
|
||||||
|
|
||||||
|
/** @addtogroup DiscordDataWrapJSON JSON Conversion
|
||||||
|
* @brief Helpers for converting Discord data types to/from JSON
|
||||||
|
* @{ */
|
||||||
|
|
||||||
|
/* forward declaration */
|
||||||
|
struct jsmnf_pair;
|
||||||
|
struct jsonb;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Transform a wrapped Discord data type into a JSON string
|
||||||
|
*
|
||||||
|
* @param member the wrapped Discord data type
|
||||||
|
* @param p_buf pointer to the JSON buffer
|
||||||
|
* @param p_bufsize pointer to the JSON buffer size
|
||||||
|
* @CCORD_return
|
||||||
|
*/
|
||||||
|
CCORDcode discord_data_wrap_to_json(const struct discord_data_wrap *member,
|
||||||
|
char *p_buf[],
|
||||||
|
size_t *p_bufsize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Transform a Discord data type into a JSON string
|
||||||
|
*
|
||||||
|
* @param _symbol the Discord data type symbol (with struct or union)
|
||||||
|
* @param _client the client created with discord_from_token()
|
||||||
|
* @param _data the Discord data type to be transformed
|
||||||
|
* @param _p_buf pointer to the JSON buffer
|
||||||
|
* @param _p_bufsize pointer to the JSON buffer size
|
||||||
|
* @CCORD_return
|
||||||
|
*/
|
||||||
|
#define discord_data_to_json(_symbol, _client, _data, _p_buf, _p_bufsize) \
|
||||||
|
discord_data_wrap_to_json( \
|
||||||
|
discord_data_wrap_from(_symbol, _client, _data), _p_buf, _p_bufsize)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Transform a wrapped Discord data type into a jsonb handle
|
||||||
|
*
|
||||||
|
* @param jb the jsonb handle
|
||||||
|
* @param member the wrapped Discord data type
|
||||||
|
* @param p_buf pointer to the JSON buffer
|
||||||
|
* @param p_bufsize pointer to the JSON buffer size
|
||||||
|
* @CCORD_return
|
||||||
|
*/
|
||||||
|
CCORDcode discord_data_wrap_to_jsonb(struct jsonb *jb,
|
||||||
|
const struct discord_data_wrap *member,
|
||||||
|
char *p_buf[],
|
||||||
|
size_t *p_bufsize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Transform a Discord data type into a jsonb handle
|
||||||
|
*
|
||||||
|
* @param _symbol the Discord data type symbol (with struct or union)
|
||||||
|
* @param _client the client created with discord_from_token()
|
||||||
|
* @param _jb the jsonb handle
|
||||||
|
* @param _data the Discord data type to be transformed
|
||||||
|
* @param _p_buf pointer to the JSON buffer
|
||||||
|
* @param _p_bufsize pointer to the JSON buffer size
|
||||||
|
* @CCORD_return
|
||||||
|
*/
|
||||||
|
#define discord_data_to_jsonb(_symbol, _client, _jb, _data, _p_buf, \
|
||||||
|
_p_bufsize) \
|
||||||
|
discord_data_wrap_to_jsonb( \
|
||||||
|
_jb, discord_data_wrap_from(_symbol, _client, _data), _p_buf, \
|
||||||
|
_p_bufsize)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse a JSON string and fill a wrapped Discord data type
|
||||||
|
*
|
||||||
|
* @param json the JSON string
|
||||||
|
* @param len length of @ref json
|
||||||
|
* @param root the root wrapped Discord data type
|
||||||
|
* @CCORD_return
|
||||||
|
*/
|
||||||
|
CCORDcode discord_data_wrap_from_json(const char *json,
|
||||||
|
size_t len,
|
||||||
|
struct discord_data_wrap *root);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse a JSON string and fill a Discord data type
|
||||||
|
*
|
||||||
|
* @param _symbol the Discord data type symbol (with struct or union)
|
||||||
|
* @param _client the client created with discord_from_token()
|
||||||
|
* @param _json the JSON string
|
||||||
|
* @param _len length of @ref json
|
||||||
|
* @param _data the Discord data type to be filled
|
||||||
|
* @CCORD_return
|
||||||
|
*/
|
||||||
|
#define discord_data_from_json(_symbol, _client, _json, _len, _data) \
|
||||||
|
discord_data_wrap_from_json( \
|
||||||
|
_json, _len, discord_data_wrap_from(_symbol, _client, _data))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse a jsmnf_pair and fill a wrapped Discord data type
|
||||||
|
*
|
||||||
|
* @param p the jsmnf_pair
|
||||||
|
* @param json the JSON string
|
||||||
|
* @param length length of @ref json
|
||||||
|
* @param member the wrapped Discord data type
|
||||||
|
* @CCORD_return
|
||||||
|
*/
|
||||||
|
CCORDcode discord_data_wrap_from_jsmnf(const struct jsmnf_pair *p,
|
||||||
|
const char *json,
|
||||||
|
size_t length,
|
||||||
|
const struct discord_data_wrap *member);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse a jsmnf_pair and fill a Discord data type
|
||||||
|
*
|
||||||
|
* @param _client the client created with discord_from_token()
|
||||||
|
* @param _p the jsmnf_pair
|
||||||
|
* @param _json the JSON string
|
||||||
|
* @param _length length of @ref json
|
||||||
|
* @param _type the Discord data type symbol (without struct/union)
|
||||||
|
* @param _data the Discord data type to be filled
|
||||||
|
* @CCORD_return
|
||||||
|
*/
|
||||||
|
#define discord_data_from_jsmnf(_client, _p, _json, _length, _type, _data) \
|
||||||
|
discord_data_wrap_from_jsmnf( \
|
||||||
|
_p, _json, _length, discord_data_wrap_from(_type, _client, _data))
|
||||||
|
|
||||||
|
/** @} DiscordDataWrapJSON */
|
||||||
|
|
||||||
|
/** @} DiscordDataWrap */
|
||||||
|
|
||||||
/** @addtogroup DiscordTimer Timer
|
/** @addtogroup DiscordTimer Timer
|
||||||
* @brief Schedule callbacks to be called in the future
|
* @brief Schedule callbacks to be called in the future
|
||||||
* @{ */
|
* @{ */
|
||||||
|
|||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
#include "gencodecs.h"
|
|
||||||
|
|
||||||
#ifdef GENCODECS_HEADER
|
|
||||||
PP_INCLUDE(<inttypes.h>)
|
|
||||||
PP_INCLUDE("carray.h")
|
|
||||||
PP_INCLUDE("cog-utils.h")
|
|
||||||
PP_INCLUDE("types.h")
|
|
||||||
PP_INCLUDE("concord-error.h")
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* Custom JSON encoding macros */
|
|
||||||
#define GENCODECS_JSON_ENCODER_PTR_json_char(b, buf, size, _var, _type) \
|
|
||||||
if (0 > (code = jsonb_token_auto(b, buf, size, _var, \
|
|
||||||
_var ? strlen(_var) : 0))) \
|
|
||||||
return code
|
|
||||||
#define GENCODECS_JSON_ENCODER_size_t(b, buf, size, _var, _type) \
|
|
||||||
{ \
|
|
||||||
char tok[64]; \
|
|
||||||
int toklen; \
|
|
||||||
toklen = sprintf(tok, "%zu", _var); \
|
|
||||||
if (0 > (code = jsonb_token_auto(b, buf, size, tok, toklen))) \
|
|
||||||
return code; \
|
|
||||||
}
|
|
||||||
#define GENCODECS_JSON_ENCODER_uint64_t(b, buf, size, _var, _type) \
|
|
||||||
{ \
|
|
||||||
char tok[64]; \
|
|
||||||
int toklen; \
|
|
||||||
toklen = sprintf(tok, "%" PRIu64, _var); \
|
|
||||||
if (0 > (code = jsonb_string_auto(b, buf, size, tok, toklen))) \
|
|
||||||
return code; \
|
|
||||||
}
|
|
||||||
#define GENCODECS_JSON_ENCODER_u64snowflake GENCODECS_JSON_ENCODER_uint64_t
|
|
||||||
#define GENCODECS_JSON_ENCODER_u64bitmask GENCODECS_JSON_ENCODER_uint64_t
|
|
||||||
#define GENCODECS_JSON_ENCODER_u64unix_ms(b, buf, size, _var, _type) \
|
|
||||||
{ \
|
|
||||||
char tok[64]; \
|
|
||||||
int toklen = cog_unix_ms_to_iso8601(tok, sizeof(tok), _var); \
|
|
||||||
if (0 > (code = jsonb_string_auto(b, buf, size, tok, toklen))) \
|
|
||||||
return code; \
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom JSON decoding macros */
|
|
||||||
#define GENCODECS_JSON_DECODER_PTR_json_char(_f, _js, _var, _type) \
|
|
||||||
if (_f) { \
|
|
||||||
_var = _gc_strndup(js + _f->v->start, _f->v->end - _f->v->start); \
|
|
||||||
ret += _f->v->end - _f->v->start; \
|
|
||||||
}
|
|
||||||
#define GENCODECS_JSON_DECODER_size_t(_f, _js, _var, _type) \
|
|
||||||
if (_f && _f->v->type == JSMN_PRIMITIVE) \
|
|
||||||
_var = (size_t)strtoull(_js + _f->v->start, NULL, 10)
|
|
||||||
#define GENCODECS_JSON_DECODER_uint64_t(_f, _js, _var, _type) \
|
|
||||||
if (_f) sscanf(_js + _f->v->start, "%" SCNu64, &_var)
|
|
||||||
#define GENCODECS_JSON_DECODER_u64snowflake GENCODECS_JSON_DECODER_uint64_t
|
|
||||||
#define GENCODECS_JSON_DECODER_u64bitmask GENCODECS_JSON_DECODER_uint64_t
|
|
||||||
#define GENCODECS_JSON_DECODER_u64unix_ms(_f, _js, _var, _type) \
|
|
||||||
if (_f && _f->v->type == JSMN_STRING) \
|
|
||||||
cog_iso8601_to_unix_ms(_js + _f->v->start, _f->v->end - _f->v->start, &_var)
|
|
||||||
|
|
||||||
/* Custom field macros */
|
|
||||||
#define FIELD_SNOWFLAKE(_name) \
|
|
||||||
FIELD_PRINTF(_name, u64snowflake, "\"%" PRIu64 "\"", "%" SCNu64)
|
|
||||||
#define FIELD_BITMASK(_name) \
|
|
||||||
FIELD_PRINTF(_name, u64bitmask, "\"%" PRIu64 "\"", "%" SCNu64)
|
|
||||||
#define FIELD_TIMESTAMP(_name) \
|
|
||||||
FIELD_CUSTOM(_name, #_name, u64unix_ms, DECOR_BLANK, INIT_BLANK, \
|
|
||||||
CLEANUP_BLANK, GENCODECS_JSON_ENCODER_u64unix_ms, \
|
|
||||||
GENCODECS_JSON_DECODER_u64unix_ms, (u64unix_ms)0)
|
|
||||||
|
|
||||||
/* if GENCODECS_READ is not specified then generate for all files */
|
|
||||||
#ifndef GENCODECS_READ
|
|
||||||
#define GENCODECS_READ "all.PRE.h"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#include "gencodecs-process.PRE.h"
|
|
||||||
Vendored
+1239
-10187
File diff suppressed because it is too large
Load Diff
-27
@@ -1,27 +0,0 @@
|
|||||||
#ifndef GENCODECS_READ
|
|
||||||
#error "Missing GENCODECS_READ definition"
|
|
||||||
#else
|
|
||||||
|
|
||||||
#define DATA (1 << 1)
|
|
||||||
#define JSON_DECODER (1 << 2)
|
|
||||||
#define JSON_ENCODER (1 << 3)
|
|
||||||
#define JSON (JSON_DECODER | JSON_ENCODER)
|
|
||||||
|
|
||||||
#define GENCODECS_RECIPE DATA
|
|
||||||
#include "recipes/struct.h"
|
|
||||||
#undef GENCODECS_RECIPE
|
|
||||||
|
|
||||||
#define GENCODECS_RECIPE JSON_DECODER
|
|
||||||
#include "recipes/json-decoder.h"
|
|
||||||
#undef GENCODECS_RECIPE
|
|
||||||
|
|
||||||
#define GENCODECS_RECIPE JSON_ENCODER
|
|
||||||
#include "recipes/json-encoder.h"
|
|
||||||
#undef GENCODECS_RECIPE
|
|
||||||
|
|
||||||
#undef DATA
|
|
||||||
#undef JSON_DECODER
|
|
||||||
#undef JSON_ENCODER
|
|
||||||
#undef JSON
|
|
||||||
|
|
||||||
#endif /* GENCODECS_READ */
|
|
||||||
Vendored
-74
@@ -1,74 +0,0 @@
|
|||||||
#ifndef GENCODECS_H
|
|
||||||
#define GENCODECS_H
|
|
||||||
|
|
||||||
/* Allow symbols usage without GENCODECS_ prefix */
|
|
||||||
#ifndef GENCODECS_USE_PREFIX
|
|
||||||
# define PP_INCLUDE GENCODECS_PP_INCLUDE
|
|
||||||
# define PP_DEFINE GENCODECS_PP_DEFINE
|
|
||||||
# define PP GENCODECS_PP
|
|
||||||
|
|
||||||
# define COND_WRITE GENCODECS_COND_WRITE
|
|
||||||
# define COND_END GENCODECS_COND_END
|
|
||||||
|
|
||||||
# define PUB_STRUCT GENCODECS_PUB_STRUCT
|
|
||||||
# define STRUCT GENCODECS_STRUCT
|
|
||||||
# define FIELD_CUSTOM GENCODECS_FIELD_CUSTOM
|
|
||||||
# define FIELD_PRINTF GENCODECS_FIELD_PRINTF
|
|
||||||
# define FIELD GENCODECS_FIELD
|
|
||||||
# define FIELD_STRUCT_PTR GENCODECS_FIELD_STRUCT_PTR
|
|
||||||
# define FIELD_PTR GENCODECS_FIELD_PTR
|
|
||||||
# define FIELD_ENUM GENCODECS_FIELD_ENUM
|
|
||||||
# define STRUCT_END GENCODECS_STRUCT_END
|
|
||||||
|
|
||||||
# define PUB_LIST GENCODECS_PUB_LIST
|
|
||||||
# define LIST GENCODECS_LIST
|
|
||||||
# define LISTTYPE GENCODECS_LISTTYPE
|
|
||||||
# define LISTTYPE_STRUCT GENCODECS_LISTTYPE_STRUCT
|
|
||||||
# define LISTTYPE_PTR GENCODECS_LISTTYPE_PTR
|
|
||||||
# define LIST_END GENCODECS_LIST_END
|
|
||||||
|
|
||||||
# define ENUM GENCODECS_ENUM
|
|
||||||
# define ENUM_END GENCODECS_ENUM_END
|
|
||||||
# define ENUMERATOR GENCODECS_ENUMERATOR
|
|
||||||
# define ENUMERATOR_LAST GENCODECS_ENUMERATOR_LAST
|
|
||||||
# define ENUMERATOR_END GENCODECS_ENUMERATOR_END
|
|
||||||
#endif /* GENCODECS_USE_PREFIX */
|
|
||||||
|
|
||||||
#ifndef GENCODECS_HEADER
|
|
||||||
# ifdef GENCODECS_DATA
|
|
||||||
GENCODECS_PP_INCLUDE(<stdio.h>)
|
|
||||||
GENCODECS_PP_INCLUDE(<stdlib.h>)
|
|
||||||
GENCODECS_PP_INCLUDE(<string.h>)
|
|
||||||
# ifdef GENCODECS_INIT
|
|
||||||
GENCODECS_PP_INCLUDE("carray.h")
|
|
||||||
# endif
|
|
||||||
# if defined(GENCODECS_JSON_DECODER) && defined(GENCODECS_FORWARD)
|
|
||||||
static char *
|
|
||||||
_gc_strndup(const char *src, size_t len)
|
|
||||||
{
|
|
||||||
char *dest = malloc(len + 1);
|
|
||||||
memcpy(dest, src, len);
|
|
||||||
dest[len] = '\0';
|
|
||||||
return dest;
|
|
||||||
}
|
|
||||||
# endif /* GENCODECS_JSON_DECODER && GENCODECS_FORWARD */
|
|
||||||
# endif /* GENCODECS_DATA */
|
|
||||||
#else
|
|
||||||
GENCODECS_PP_INCLUDE(<stddef.h>)
|
|
||||||
GENCODECS_PP_INCLUDE(<stdbool.h>)
|
|
||||||
# ifdef GENCODECS_JSON_DECODER
|
|
||||||
GENCODECS_PP_DEFINE(JSMN_STRICT)
|
|
||||||
GENCODECS_PP_DEFINE(JSMN_HEADER)
|
|
||||||
GENCODECS_PP_INCLUDE("jsmn.h")
|
|
||||||
GENCODECS_PP_INCLUDE("jsmn-find.h")
|
|
||||||
# endif
|
|
||||||
# ifdef GENCODECS_JSON_ENCODER
|
|
||||||
GENCODECS_PP_DEFINE(JSONB_HEADER)
|
|
||||||
GENCODECS_PP_INCLUDE("json-build.h")
|
|
||||||
# endif
|
|
||||||
#endif /* GENCODECS_HEADER */
|
|
||||||
|
|
||||||
#define GENCODECS_PP(_description)
|
|
||||||
#define GENCODECS_PP_DEFINE(_description)
|
|
||||||
|
|
||||||
#endif /* GENCODECS_H */
|
|
||||||
Vendored
+4
-2
@@ -689,8 +689,10 @@ logmod_init(struct logmod *logmod,
|
|||||||
LOGMOD_API logmod_err
|
LOGMOD_API logmod_err
|
||||||
logmod_cleanup(struct logmod *logmod)
|
logmod_cleanup(struct logmod *logmod)
|
||||||
{
|
{
|
||||||
memset((void *)logmod->loggers, 0,
|
LOGMOD_EXPECT(logmod != NULL, LOGMOD_BAD_PARAMETER);
|
||||||
logmod->real_length * sizeof *logmod->loggers);
|
if (logmod->loggers)
|
||||||
|
memset((void *)logmod->loggers, 0,
|
||||||
|
logmod->real_length * sizeof *logmod->loggers);
|
||||||
memset(logmod, 0, sizeof *logmod);
|
memset(logmod, 0, sizeof *logmod);
|
||||||
return LOGMOD_OK;
|
return LOGMOD_OK;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+163
@@ -0,0 +1,163 @@
|
|||||||
|
#ifndef REFLECTC_H
|
||||||
|
#define REFLECTC_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
/* Allow consumers to rename the runtime namespace (`reflectc_*` /
|
||||||
|
* `REFLECTC_*`). */
|
||||||
|
#ifndef REFLECTC_PREFIX
|
||||||
|
#define REFLECTC_PREFIX reflectc
|
||||||
|
#endif /* REFLECTC_PREFIX */
|
||||||
|
|
||||||
|
#ifndef REFLECTC_PREFIX_UPPER
|
||||||
|
#define REFLECTC_PREFIX_UPPER REFLECTC
|
||||||
|
#endif /* REFLECTC_PREFIX_UPPER */
|
||||||
|
|
||||||
|
#define __CAT(_a, _b) _a##_b
|
||||||
|
#define _CAT(_a, _b) __CAT(_a, _b)
|
||||||
|
#define REFLECTC_NS(_sym) _CAT(REFLECTC_PREFIX, _sym)
|
||||||
|
#define REFLECTC_NS_UPPER(_sym) _CAT(REFLECTC_PREFIX_UPPER, _sym)
|
||||||
|
|
||||||
|
/* Forward declarations */
|
||||||
|
struct REFLECTC_PREFIX;
|
||||||
|
struct REFLECTC_NS(_template);
|
||||||
|
struct REFLECTC_NS(_wrap);
|
||||||
|
struct REFLECTC_NS(_wrap_mut);
|
||||||
|
|
||||||
|
#define REFLECTC_TYPES(_sym) REFLECTC_NS_UPPER(_TYPES__##_sym)
|
||||||
|
|
||||||
|
enum REFLECTC_NS(_types) {
|
||||||
|
REFLECTC_TYPES(void),
|
||||||
|
REFLECTC_TYPES(bool),
|
||||||
|
REFLECTC_TYPES(char),
|
||||||
|
REFLECTC_TYPES(short),
|
||||||
|
REFLECTC_TYPES(int),
|
||||||
|
REFLECTC_TYPES(long),
|
||||||
|
REFLECTC_TYPES(float),
|
||||||
|
REFLECTC_TYPES(double),
|
||||||
|
REFLECTC_TYPES(struct),
|
||||||
|
REFLECTC_TYPES(union),
|
||||||
|
REFLECTC_TYPES(enum),
|
||||||
|
REFLECTC_TYPES(EXTEND)
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef struct REFLECTC_NS(_wrap)
|
||||||
|
* (*REFLECTC_NS(_from_cb))(struct REFLECTC_PREFIX *registry,
|
||||||
|
void *self,
|
||||||
|
struct REFLECTC_NS(_wrap) * root);
|
||||||
|
|
||||||
|
struct REFLECTC_NS(_template) {
|
||||||
|
const size_t size;
|
||||||
|
const struct {
|
||||||
|
const char *const buf;
|
||||||
|
const size_t length;
|
||||||
|
} qualifier, decorator, name, alias, dimensions;
|
||||||
|
const enum REFLECTC_NS(_types) type;
|
||||||
|
const unsigned long attrs;
|
||||||
|
const struct {
|
||||||
|
const size_t length;
|
||||||
|
const struct REFLECTC_NS(_template) * array;
|
||||||
|
} members;
|
||||||
|
const REFLECTC_NS(_from_cb) from_cb;
|
||||||
|
};
|
||||||
|
|
||||||
|
#define _REFLECTC_PICKER_const struct REFLECTC_NS(_wrap)
|
||||||
|
#define _REFLECTC_PICKER___BLANK__ struct REFLECTC_NS(_wrap_mut)
|
||||||
|
|
||||||
|
#define REFLECTC_INSTANCE_FIELDS(_qualifier) \
|
||||||
|
_qualifier size_t length; \
|
||||||
|
struct REFLECTC_PREFIX *_qualifier registry; \
|
||||||
|
_qualifier void *_qualifier ptr_value; \
|
||||||
|
_qualifier struct { \
|
||||||
|
_qualifier size_t length; \
|
||||||
|
_qualifier _REFLECTC_PICKER_##_qualifier *_qualifier array; \
|
||||||
|
} members
|
||||||
|
|
||||||
|
struct REFLECTC_NS(_wrap) {
|
||||||
|
const struct REFLECTC_NS(_template) * tmpl;
|
||||||
|
REFLECTC_INSTANCE_FIELDS(const);
|
||||||
|
};
|
||||||
|
|
||||||
|
#define __BLANK__
|
||||||
|
struct REFLECTC_NS(_wrap_mut) {
|
||||||
|
const struct REFLECTC_NS(_template) * tmpl;
|
||||||
|
REFLECTC_INSTANCE_FIELDS(__BLANK__);
|
||||||
|
};
|
||||||
|
#undef __BLANK__
|
||||||
|
#undef REFLECTC_INSTANCE_FIELDS
|
||||||
|
#undef _REFLECTC_PICKER_const
|
||||||
|
#undef _REFLECTC_PICKER___BLANK__
|
||||||
|
|
||||||
|
struct REFLECTC_PREFIX *REFLECTC_NS(_init)(void);
|
||||||
|
void REFLECTC_NS(_dispose)(struct REFLECTC_PREFIX *table);
|
||||||
|
struct REFLECTC_NS(_wrap)
|
||||||
|
* REFLECTC_NS(_find)(const struct REFLECTC_PREFIX *table,
|
||||||
|
const void *value);
|
||||||
|
int REFLECTC_NS(_put)(struct REFLECTC_PREFIX *table,
|
||||||
|
void *value,
|
||||||
|
struct REFLECTC_NS(_wrap) * wrapper);
|
||||||
|
void REFLECTC_NS(_erase)(struct REFLECTC_PREFIX *table, const void *value);
|
||||||
|
|
||||||
|
size_t REFLECTC_NS(_length)(const struct REFLECTC_NS(_wrap) * member);
|
||||||
|
|
||||||
|
size_t REFLECTC_NS(_get_pos)(const struct REFLECTC_NS(_wrap) * root,
|
||||||
|
const char *const name,
|
||||||
|
const size_t len);
|
||||||
|
|
||||||
|
/* Serialized name of a member: its alias when one is declared in the
|
||||||
|
* recipe (8th tuple slot), otherwise its C member name. Use this when
|
||||||
|
* mapping members to an external format (e.g. JSON keys). */
|
||||||
|
const char *REFLECTC_NS(_alias)(const struct REFLECTC_NS(_wrap) * member,
|
||||||
|
size_t *length);
|
||||||
|
|
||||||
|
void REFLECTC_NS(_array)(const struct REFLECTC_NS(_wrap) * root,
|
||||||
|
const size_t length);
|
||||||
|
|
||||||
|
unsigned REFLECTC_NS(_get_pointer_depth)(const struct REFLECTC_NS(_wrap)
|
||||||
|
* member);
|
||||||
|
|
||||||
|
const void *REFLECTC_NS(_deref)(const struct REFLECTC_NS(_wrap) * field);
|
||||||
|
const void *REFLECTC_NS(_memcpy)(const struct REFLECTC_NS(_wrap) * field,
|
||||||
|
const void *map,
|
||||||
|
const size_t size);
|
||||||
|
const char *REFLECTC_NS(_string)(const struct REFLECTC_NS(_wrap) * dest,
|
||||||
|
const char src[],
|
||||||
|
const size_t size);
|
||||||
|
void *REFLECTC_NS(_resolve)(const struct REFLECTC_NS(_wrap) * member);
|
||||||
|
int REFLECTC_NS(_expand)(struct REFLECTC_PREFIX *registry,
|
||||||
|
const struct REFLECTC_NS(_wrap) * member);
|
||||||
|
|
||||||
|
typedef int (*REFLECTC_NS(_visit_cb))(const struct REFLECTC_NS(_wrap) * member,
|
||||||
|
void *ctx);
|
||||||
|
|
||||||
|
void REFLECTC_NS(_cleanup)(struct REFLECTC_PREFIX *registry,
|
||||||
|
struct REFLECTC_NS(_wrap) * member);
|
||||||
|
void REFLECTC_NS(_cleanup_members)(struct REFLECTC_PREFIX *registry,
|
||||||
|
struct REFLECTC_NS(_wrap) * member);
|
||||||
|
int REFLECTC_NS(_for_each)(const struct REFLECTC_NS(_wrap) * root,
|
||||||
|
REFLECTC_NS(_visit_cb) cb,
|
||||||
|
void *ctx);
|
||||||
|
const struct REFLECTC_NS(_wrap)
|
||||||
|
* REFLECTC_NS(_require_member)(const struct REFLECTC_NS(_wrap) * root,
|
||||||
|
const char *name,
|
||||||
|
size_t len);
|
||||||
|
int REFLECTC_NS(_is_pointer_type)(const struct REFLECTC_NS(_wrap) * member);
|
||||||
|
int REFLECTC_NS(_is_null)(const struct REFLECTC_NS(_wrap) * member);
|
||||||
|
int REFLECTC_NS(_expand_all)(struct REFLECTC_PREFIX *registry,
|
||||||
|
struct REFLECTC_NS(_wrap) * root);
|
||||||
|
|
||||||
|
#define REFLECTC_LOOKUP(_container, _namespace, _member_name, _root) \
|
||||||
|
(size_t)(REFLECTC_NS_UPPER(_LOOKUP__##_namespace##__##_member_name))
|
||||||
|
|
||||||
|
const void *REFLECTC_NS(_get)(const struct REFLECTC_NS(_wrap) * member);
|
||||||
|
const void *REFLECTC_NS(_set)(const struct REFLECTC_NS(_wrap) * member,
|
||||||
|
const void *value,
|
||||||
|
size_t size);
|
||||||
|
const void *REFLECTC_NS(_get_member)(const struct REFLECTC_NS(_wrap) * root,
|
||||||
|
size_t pos);
|
||||||
|
const void *REFLECTC_NS(_set_member)(const struct REFLECTC_NS(_wrap) * root,
|
||||||
|
size_t pos,
|
||||||
|
const void *value,
|
||||||
|
size_t size);
|
||||||
|
|
||||||
|
#endif /* REFLECTC_H */
|
||||||
Reference in New Issue
Block a user