Compare commits
33
Commits
7c2a2aecd1
..
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
|
||
|
|
9ea5d69cc1
|
||
|
|
97edfc59fb
|
||
|
|
b72676659a
|
||
|
|
01741ba375
|
||
|
|
73809eaa7c
|
@@ -1,3 +1,8 @@
|
||||
.env
|
||||
build/
|
||||
build*/
|
||||
.DS_Store
|
||||
config.json
|
||||
.vscode
|
||||
.vscode/
|
||||
sqlite/*.sqlite
|
||||
|
||||
Vendored
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Linux",
|
||||
"includePath": [
|
||||
"${workspaceFolder}/**",
|
||||
"${workspaceFolder}/include"
|
||||
],
|
||||
"defines": [],
|
||||
"compilerPath": "/usr/bin/clang",
|
||||
"cStandard": "c17",
|
||||
"intelliSenseMode": "linux-clang-x64"
|
||||
}
|
||||
],
|
||||
"version": 4
|
||||
}
|
||||
+164
-17
@@ -198,7 +198,7 @@ else()
|
||||
-Wshadow
|
||||
-Wconversion
|
||||
-Wsign-conversion
|
||||
-Wcast-qual
|
||||
#-Wcast-qual
|
||||
-Wcast-align
|
||||
-Wstrict-prototypes
|
||||
-Wmissing-prototypes
|
||||
@@ -215,6 +215,7 @@ else()
|
||||
-Wundef
|
||||
-Winit-self
|
||||
-Wmissing-include-dirs
|
||||
-Wno-discarded-qualifiers # Temp
|
||||
)
|
||||
|
||||
if(CMAKE_C_COMPILER_ID MATCHES "GNU")
|
||||
@@ -245,6 +246,8 @@ else()
|
||||
hammy_append_supported_c_flags(HAMMY_ANALYZER_FLAGS
|
||||
-fanalyzer-verbosity=${ANALYZER_VERBOSITY}
|
||||
-fanalyzer
|
||||
--param=analyzer-checker=taint
|
||||
--Wanalyzer-too-complex
|
||||
)
|
||||
endif()
|
||||
elseif(CMAKE_C_COMPILER_ID MATCHES "Clang")
|
||||
@@ -337,7 +340,14 @@ if(HAMMY_ENABLE_LTO)
|
||||
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
|
||||
# two-stage arrangement: FetchContent clones it at configure time (recursively,
|
||||
@@ -358,7 +368,7 @@ include(ExternalProject)
|
||||
FetchContent_Declare(
|
||||
concord
|
||||
GIT_REPOSITORY https://github.com/Cogmasters/concord.git
|
||||
GIT_TAG dev
|
||||
GIT_TAG v3.0.1
|
||||
GIT_SHALLOW TRUE
|
||||
GIT_PROGRESS TRUE
|
||||
)
|
||||
@@ -388,38 +398,152 @@ else()
|
||||
# which is before Concord has been built.
|
||||
file(MAKE_DIRECTORY "${CONCORD_INCLUDE_DIR}")
|
||||
|
||||
# Upstream's `make install` is three shell globs relative to the working
|
||||
# directory, fed to install(1). When one matches nothing the shell passes it
|
||||
# through literally and install reports `cannot stat 'include/*.h'`, which
|
||||
# says nothing about which glob mattered or why. Do the copy ourselves with
|
||||
# absolute paths and name the two failure modes that actually occur.
|
||||
#
|
||||
# The globs run at install time rather than configure time because
|
||||
# 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")
|
||||
file(CONFIGURE
|
||||
OUTPUT "${CONCORD_INSTALL_SCRIPT}"
|
||||
@ONLY
|
||||
CONTENT [[
|
||||
cmake_policy(SET CMP0057 NEW)
|
||||
|
||||
file(MAKE_DIRECTORY "@CONCORD_INCLUDE_DIR@/concord" "@CONCORD_PREFIX@/lib")
|
||||
|
||||
# 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
|
||||
# combined glob would hide it behind whichever siblings still matched.
|
||||
set(_hdrs "")
|
||||
foreach(_dir include core generated)
|
||||
file(GLOB _found "@concord_SOURCE_DIR@/${_dir}/*.h")
|
||||
if(NOT _found)
|
||||
message(FATAL_ERROR
|
||||
"Concord: no headers in @concord_SOURCE_DIR@/${_dir} -- the source "
|
||||
"tree is incomplete; remove build/_deps and reconfigure to re-clone")
|
||||
endif()
|
||||
list(APPEND _hdrs ${_found})
|
||||
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")
|
||||
|
||||
# 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.*")
|
||||
if(NOT _libs)
|
||||
message(FATAL_ERROR "Concord: build produced no library in @concord_SOURCE_DIR@/lib")
|
||||
endif()
|
||||
file(COPY ${_libs} DESTINATION "@CONCORD_PREFIX@/lib")
|
||||
]])
|
||||
|
||||
# Concord gets our instrumentation but not our warning set: ASan only sees a
|
||||
# bug if the translation unit that owns the memory was compiled with it, and
|
||||
# Concord allocates plenty that our code then touches. CFLAGS goes through
|
||||
# the environment rather than the make command line because Concord's
|
||||
# Makefile appends its own -I flags to it; a command-line assignment would
|
||||
# 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
|
||||
# generator, with plain '=' assignments that the environment cannot override.
|
||||
# On a system where cc is GCC and we build with Clang, the generator is then
|
||||
# handed our Clang-only flags (-fno-sanitize=function) and dies. Command-line
|
||||
# assignments do beat makefile assignments, so force the compiler there --
|
||||
# while leaving CFLAGS in the environment, per the note above.
|
||||
#
|
||||
# gencodecs-pp is a build-time text filter, run as `cpp ... | ./gencodecs-pp`.
|
||||
# ASan's exit-time leak check would turn a leak in that throwaway tool into a
|
||||
# build failure reported as a broken pipeline, so switch it off for the
|
||||
# duration of Concord's build only; our own binary is unaffected.
|
||||
ExternalProject_Add(concord_external
|
||||
SOURCE_DIR "${concord_SOURCE_DIR}"
|
||||
DOWNLOAD_COMMAND "" # FetchContent already cloned it
|
||||
UPDATE_COMMAND ""
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_IN_SOURCE TRUE # upstream's Makefile has no out-of-tree mode
|
||||
BUILD_COMMAND ${CMAKE_COMMAND} -E env "CC=${CMAKE_C_COMPILER}" "CFLAGS=${CONCORD_CFLAGS}"
|
||||
# Upstream tracks nothing we can express as a byproduct, so the stamp
|
||||
# would happily report "built" after a `make clean` emptied lib/.
|
||||
BUILD_ALWAYS TRUE
|
||||
BUILD_COMMAND ${CMAKE_COMMAND} -E env
|
||||
"CC=${CMAKE_C_COMPILER}"
|
||||
"CFLAGS=${CONCORD_CFLAGS}"
|
||||
"ASAN_OPTIONS=detect_leaks=0"
|
||||
${HAMMY_MAKE_EXECUTABLE}
|
||||
INSTALL_COMMAND ${HAMMY_MAKE_EXECUTABLE} install "PREFIX=${CONCORD_PREFIX}"
|
||||
"CC=${CMAKE_C_COMPILER}"
|
||||
"HOSTCC=${CMAKE_C_COMPILER}"
|
||||
"CPP=${CMAKE_C_COMPILER} -E"
|
||||
INSTALL_COMMAND ${CMAKE_COMMAND} -P "${CONCORD_INSTALL_SCRIPT}"
|
||||
BUILD_BYPRODUCTS "${CONCORD_LIBRARY}"
|
||||
USES_TERMINAL_BUILD TRUE
|
||||
# USES_TERMINAL_BUILD would silently disable LOG_BUILD; with
|
||||
# LOG_OUTPUT_ON_FAILURE we get the full log exactly when it matters.
|
||||
LOG_BUILD TRUE
|
||||
LOG_INSTALL TRUE
|
||||
LOG_OUTPUT_ON_FAILURE TRUE
|
||||
)
|
||||
|
||||
ExternalProject_Add_Step(concord_external mirror_headers
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CONCORD_INCLUDE_DIR}/concord"
|
||||
"${PROJECT_SOURCE_DIR}/include/concord"
|
||||
DEPENDEES install
|
||||
COMMENT "Mirroring Concord headers into include/concord"
|
||||
)
|
||||
|
||||
add_library(concord::concord STATIC IMPORTED GLOBAL)
|
||||
set_target_properties(concord::concord PROPERTIES
|
||||
IMPORTED_LOCATION "${CONCORD_LIBRARY}"
|
||||
@@ -432,6 +556,15 @@ else()
|
||||
set(HAMMY_CONCORD_EXTERNAL concord_external)
|
||||
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
|
||||
# ---------------------------------------------------------
|
||||
@@ -456,7 +589,20 @@ target_include_directories(hammy PRIVATE
|
||||
${PROJECT_SOURCE_DIR}/include
|
||||
)
|
||||
|
||||
target_link_libraries(hammy PRIVATE concord::concord)
|
||||
target_include_directories(hammy SYSTEM PRIVATE
|
||||
${PROJECT_SOURCE_DIR}/third_party
|
||||
)
|
||||
|
||||
# Concord's headers arrive through concord::concord. CMake passes an imported
|
||||
# target's interface includes as -isystem, so upstream's headers never trip our
|
||||
# warning set and no second copy under third_party/ is needed.
|
||||
|
||||
target_link_libraries(hammy PRIVATE concord::concord SQLite::SQLite3)
|
||||
|
||||
if (NOT MSVC)
|
||||
target_link_libraries(hammy PRIVATE m)
|
||||
endif()
|
||||
|
||||
if(HAMMY_CONCORD_EXTERNAL)
|
||||
add_dependencies(hammy ${HAMMY_CONCORD_EXTERNAL})
|
||||
endif()
|
||||
@@ -501,7 +647,8 @@ message(STATUS "hammy: sanitizers [Debug/Strict] ${HAMMY_SANITIZER_FLAGS}")
|
||||
message(STATUS "hammy: static analyzer [Analyzer] ${HAMMY_ANALYZER_FLAGS}")
|
||||
message(STATUS "hammy: hardening ${HAMMY_ENABLE_HARDENING}")
|
||||
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)
|
||||
message(STATUS "hammy: NOTE - Analyzer config has no static analyzer on "
|
||||
"${CMAKE_C_COMPILER_ID}; use scan-build over this build tree")
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
# Hammy
|
||||
|
||||
A Discord Bot for Ham/Amateur Radio
|
||||
|
||||
# AI Disclosure
|
||||
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 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.
|
||||
|
||||
# Legal
|
||||
## Legal
|
||||
Licensed under the GPLv3 license, with the exception of other third-party libraries/code which may be included under compatible licenses.
|
||||
|
||||
Copyright (c) 2026 The Hammy Contributors
|
||||
|
||||
@@ -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"
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* @file discord-request.h
|
||||
* @ingroup DiscordInternalREST
|
||||
* @author Cogmasters
|
||||
* @brief Generic macros for initializing a @ref discord_attributes
|
||||
*/
|
||||
|
||||
#ifndef DISCORD_REQUEST_H
|
||||
#define DISCORD_REQUEST_H
|
||||
|
||||
/* helper typedefs for casting */
|
||||
typedef void (*cast_done_typed)(struct discord *,
|
||||
struct discord_response *,
|
||||
const void *);
|
||||
typedef void (*cast_init)(void *);
|
||||
typedef void (*cast_cleanup)(void *);
|
||||
typedef size_t (*cast_from_json)(const char *, size_t, void *);
|
||||
|
||||
/* helper typedef for getting sizeof of `struct discord_ret` common fields */
|
||||
typedef struct {
|
||||
DISCORD_RET_DEFAULT_FIELDS;
|
||||
} discord_ret_default_fields;
|
||||
|
||||
#define _RET_COPY_TYPED(dest, src) \
|
||||
do { \
|
||||
memcpy(&(dest), &(src), sizeof(discord_ret_default_fields)); \
|
||||
(dest).has_type = true; \
|
||||
(dest).done.typed = (cast_done_typed)(src).done; \
|
||||
(dest).sync = (src).sync; \
|
||||
} while (0)
|
||||
|
||||
#define _RET_COPY_TYPELESS(dest, src) \
|
||||
do { \
|
||||
memcpy(&(dest), &(src), sizeof(discord_ret_default_fields)); \
|
||||
(dest).has_type = false; \
|
||||
(dest).done.typeless = (src).done; \
|
||||
(dest).sync = (void *)(src).sync; \
|
||||
} while (0)
|
||||
|
||||
/**
|
||||
* @brief Helper for setting attributes for a specs-generated return struct
|
||||
*
|
||||
* @param[out] attr @ref discord_attributes handler to be initialized
|
||||
* @param[in] type datatype of the struct
|
||||
* @param[in] ret dispatch attributes
|
||||
* @param[in] _reason reason for request (if available)
|
||||
*/
|
||||
#define DISCORD_ATTR_INIT(attr, type, ret, _reason) \
|
||||
do { \
|
||||
(attr).response.size = sizeof(struct type); \
|
||||
(attr).response.init = (cast_init)type##_init; \
|
||||
(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 a specs-generated list
|
||||
*
|
||||
* @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
|
||||
*
|
||||
* @param[out] attr @ref discord_attributes handler to be initialized
|
||||
* @param[in] ret dispatch attributes
|
||||
* @param[in] _reason reason for request (if available)
|
||||
*/
|
||||
#define DISCORD_ATTR_BLANK_INIT(attr, ret, _reason) \
|
||||
do { \
|
||||
(attr).reason = _reason; \
|
||||
if (ret) _RET_COPY_TYPELESS(attr.dispatch, *ret); \
|
||||
} while (0)
|
||||
|
||||
/**
|
||||
* @brief Helper for initializing attachments ids
|
||||
*
|
||||
* @param[in,out] attchs a @ref discord_attachments to have its IDs initialized
|
||||
*/
|
||||
#define DISCORD_ATTACHMENTS_IDS_INIT(attchs) \
|
||||
do { \
|
||||
for (int i = 0; i < attchs->size; ++i) { \
|
||||
attchs->array[i].id = (u64snowflake)i; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#endif /* DISCORD_REQUEST_H */
|
||||
@@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -0,0 +1,58 @@
|
||||
#ifndef HAMMY_BOT_H
|
||||
#define HAMMY_BOT_H
|
||||
|
||||
#include <concord/discord.h>
|
||||
#include <dlibc/vector.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include <hammy/types.h>
|
||||
#include <hammy/command.h>
|
||||
#include <hammy/refdb.h>
|
||||
|
||||
struct hammy_bot_t {
|
||||
struct discord* client; // Owning reference to the client - handoff from main.c
|
||||
hammy_refdb_t* refdb; // Owning reference to sqlite - the master and each worker has their own
|
||||
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
|
||||
hammy_bot_t* hammy_bot_create();
|
||||
|
||||
// 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));
|
||||
|
||||
// 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.
|
||||
bool hammy_bot_run(hammy_bot_t* bot);
|
||||
|
||||
// Adds a command to the vector as a COPY. Commands own nothing, so the source
|
||||
// struct needs no cleanup and may be a compound literal.
|
||||
bool hammy_bot_add_command(hammy_bot_t* bot, const hammy_command_t* command);
|
||||
|
||||
// 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);
|
||||
|
||||
// Destroy bot, NULLs the passing reference
|
||||
bool hammy_bot_destroy(hammy_bot_t** bot);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,66 @@
|
||||
#ifndef HAMMY_COMMAND_H
|
||||
#define HAMMY_COMMAND_H
|
||||
|
||||
#include <concord/discord.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 {
|
||||
const char* name; // Discord command name. Not owning; expected to be a literal.
|
||||
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;
|
||||
};
|
||||
|
||||
// The built-in command table. Returns a pointer to static storage; outCount
|
||||
// receives the number of entries. Never NULL.
|
||||
const hammy_command_t* hammy_command_builtins(size_t* outCount);
|
||||
|
||||
// Linear lookup by name over a plain array. The table is small enough that
|
||||
// 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,71 @@
|
||||
#ifndef HAMMY_JOB_H
|
||||
#define HAMMY_JOB_H
|
||||
|
||||
#include <concord/discord.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include <hammy/types.h>
|
||||
#include <hammy/refdb.h>
|
||||
|
||||
// A single slash-command option. Flattened out of the interaction event.
|
||||
// Both strings are owned by the job.
|
||||
typedef struct hammy_arg_t {
|
||||
char* name;
|
||||
char* value;
|
||||
} hammy_arg_t;
|
||||
|
||||
// A unit of deferred work handed from the gateway thread to a worker.
|
||||
//
|
||||
// OWNERSHIP: a job is created by the gateway thread and, on a successful
|
||||
// hammy_pool_push(), ownership transfers to the pool. After that the creating
|
||||
// thread MUST NOT touch it again. The worker that pops it owns it and destroys
|
||||
// it. On a failed push the caller still owns it and must destroy it itself.
|
||||
struct hammy_job_t {
|
||||
char* token; // Interaction token - owning
|
||||
u64snowflake id; // Interaction ID
|
||||
u64snowflake appId; // Application ID, needed to edit the original response
|
||||
u64snowflake user; // Invoking User ID, rate limiting, logging, etc.
|
||||
char* command; // Command name - owning
|
||||
hammy_arg_t* args; // Array of size nArgs. Owning, array and contents.
|
||||
size_t nArgs;
|
||||
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
|
||||
// event may be freed by Concord the moment the handler returns.
|
||||
// Returns NULL on allocation failure.
|
||||
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.
|
||||
// Safe to call with NULL or with a pointer to NULL.
|
||||
bool hammy_job_destroy(hammy_job_t** job);
|
||||
|
||||
// Looks up an option by name. Returns NULL if absent. Result is owned by the job.
|
||||
const char* hammy_job_get_arg(const hammy_job_t* job, const char* name);
|
||||
|
||||
// Milliseconds elapsed since the job was created.
|
||||
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,
|
||||
// so client MUST be that worker's own clone, never the gateway client.
|
||||
// Does not destroy the job.
|
||||
void hammy_job_run(hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
@@ -0,0 +1,73 @@
|
||||
#ifndef HAMMY_POOL_H
|
||||
#define HAMMY_POOL_H
|
||||
|
||||
#include <concord/discord.h>
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#include <hammy/types.h>
|
||||
|
||||
// TODO: Change this accordingly; also probably make it configurable without rebuilding at some point
|
||||
#define HAMMY_POOL_DEFAULT_WORKERS 2
|
||||
#define HAMMY_POOL_DEFAULT_CAPACITY 64
|
||||
|
||||
// Technically, discord allows 15 minutes... but hell no. 30 seconds.
|
||||
#define HAMMY_JOB_MAX_AGE_MS 30000
|
||||
|
||||
typedef enum {
|
||||
HAMMY_PUSH_OK = 0, // Queued. Ownership transferred to the pool.
|
||||
HAMMY_PUSH_FULL = 1, // At capacity. Caller still owns the job.
|
||||
HAMMY_PUSH_SHUTDOWN = 2 // Pool is closing. Caller still owns the job.
|
||||
} hammy_push_result_t;
|
||||
|
||||
struct hammy_pool_t {
|
||||
pthread_mutex_t lock;
|
||||
pthread_cond_t notEmpty;
|
||||
|
||||
hammy_job_t** jobs; // Ring buffer of cap job pointers - owning while queued
|
||||
size_t head; // Next slot to pop
|
||||
size_t tail; // Next slot to push
|
||||
size_t count; // Live entries - tracked so full != empty ambiguity doesn't exist
|
||||
size_t cap;
|
||||
|
||||
size_t busy; // Workers currently processing jobs.
|
||||
bool shutdown;
|
||||
|
||||
hammy_worker_t* workers;
|
||||
size_t nWorkers;
|
||||
};
|
||||
|
||||
// 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.
|
||||
// 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);
|
||||
|
||||
// Enqueues a job. See hammy_push_result_t for who owns the job afterwards.
|
||||
// Never blocks on anything but the (uncontended, short) queue mutex, so it is
|
||||
// safe to call from the gateway thread.
|
||||
hammy_push_result_t hammy_pool_push(hammy_pool_t* pool, hammy_job_t* job);
|
||||
|
||||
// Signals all workers to finish the queue and exit, then joins them.
|
||||
// Idempotent. Queued jobs are still run, so an in-flight command still gets a
|
||||
// reply; use hammy_pool_shutdown_now() if you would rather drop them.
|
||||
void hammy_pool_shutdown(hammy_pool_t* pool);
|
||||
|
||||
// As above but discards anything still queued (each dropped job gets an
|
||||
// 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);
|
||||
|
||||
// Frees the pool. Runs hammy_pool_shutdown() first if it has not happened yet.
|
||||
// NULLs the passing reference.
|
||||
bool hammy_pool_destroy(hammy_pool_t** pool);
|
||||
|
||||
// Snapshot of queue depth and busy workers, for a /stats command or logging.
|
||||
void hammy_pool_stats(hammy_pool_t* pool, size_t* outQueued, size_t* outBusy);
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
#ifndef HAMMY_TYPES_H
|
||||
#define HAMMY_TYPES_H
|
||||
|
||||
typedef struct hammy_pool_t hammy_pool_t;
|
||||
typedef struct hammy_job_t hammy_job_t;
|
||||
typedef struct hammy_worker_t hammy_worker_t;
|
||||
typedef struct hammy_command_t hammy_command_t;
|
||||
typedef struct hammy_bot_t hammy_bot_t;
|
||||
typedef struct hammy_refdb_t hammy_refdb_t;
|
||||
typedef struct hammy_dxcc_t hammy_dxcc_t;
|
||||
typedef struct hammy_freq_priv_t hammy_freq_priv_t;
|
||||
typedef struct hammy_freq_iaru_t hammy_freq_iaru_t;
|
||||
typedef struct hammy_freq_t hammy_freq_t;
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
@@ -0,0 +1,29 @@
|
||||
#ifndef HAMMY_WORKER_H
|
||||
#define HAMMY_WORKER_H
|
||||
|
||||
#include <concord/discord.h>
|
||||
#include <hammy/types.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include <hammy/refdb.h>
|
||||
|
||||
struct hammy_worker_t {
|
||||
pthread_t thread;
|
||||
struct discord* clientCopy; // Clone of the client for concord threading safety - owning
|
||||
hammy_pool_t* pool; // Non-owning back-reference
|
||||
hammy_refdb_t* refdb; // Owning sqlite ref
|
||||
int id; // Log logging mainly
|
||||
bool started; // For joining
|
||||
};
|
||||
|
||||
// Clones client, spawns the thread. Returns false on clone or spawn failure,
|
||||
// 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);
|
||||
|
||||
// Joins the thread if it was started and cleans up the clone.
|
||||
// The caller must have already set pool->shutdown and broadcast, or this hangs.
|
||||
void hammy_worker_join(hammy_worker_t* worker);
|
||||
|
||||
#endif
|
||||
@@ -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);
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
#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/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* bot = (hammy_bot_t*)calloc(1, sizeof(*bot));
|
||||
if (!bot) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
bot->client = NULL;
|
||||
bot->commandsRegistered = false;
|
||||
bot->commands = vector_create(sizeof(hammy_command_t));
|
||||
|
||||
// Check if vector allocation errored
|
||||
if (!bot->commands) {
|
||||
free(bot);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Open a reference to sqlite
|
||||
bot->refdb = hammy_refdb_open("hammy-ref.sqlite"); // TODO: Probably don't hardcode this?
|
||||
if (!bot->refdb) {
|
||||
log_error("[bot] Failed to open ref to sqlite. Commands requiring it will be unavailable!"); // TODO: Consider making this a hard-fail
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
bool hammy_bot_add_command(hammy_bot_t* bot, const hammy_command_t* command) {
|
||||
if (!bot || !bot->commands || !command || !command->name) { return false; }
|
||||
|
||||
// Copy the command into the vector. The command owns nothing, so this is safe.
|
||||
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 true;
|
||||
}
|
||||
|
||||
bool hammy_bot_deregister_all_commands(hammy_bot_t* bot) {
|
||||
// TODO: This leaks and I don't know why. Figure it out.
|
||||
if (!bot || !bot->client) { return false; }
|
||||
|
||||
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) {
|
||||
if (!bot || !(*bot)) { return false; }
|
||||
|
||||
hammy_bot_t* b = *bot;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
free(*bot);
|
||||
*bot = NULL;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <hammy/command.h>
|
||||
#include <hammy/commands.h> // Table
|
||||
|
||||
// Handlers live in src/hammy/commands/. Declared here rather than in a header
|
||||
// so adding a command touches exactly two places: its own .c file and this
|
||||
// table.
|
||||
|
||||
// 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 },
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const hammy_command_t* hammy_command_find(const hammy_command_t* commands, size_t count,
|
||||
const char* name) {
|
||||
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];
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bool hammy_command_is_instant(const hammy_command_t* command) {
|
||||
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
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
#include <concord/discord.h>
|
||||
#include <concord/log.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <hammy/bot.h>
|
||||
#include <hammy/command.h>
|
||||
#include <hammy/job.h>
|
||||
#include <hammy/embeds.h>
|
||||
|
||||
// strdup() is POSIX, so we'll keep a local and keep the code portable.
|
||||
static char* hammy_strdup(const char* src) {
|
||||
if (!src) { return NULL; }
|
||||
|
||||
size_t len = strlen(src) + 1; // +1 for the null terminator
|
||||
char* dst = (char*)malloc(len);
|
||||
if (!dst) { return NULL; }
|
||||
|
||||
memcpy(dst, src, len);
|
||||
|
||||
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
|
||||
// member->user, DM interactions under user directly.
|
||||
static u64snowflake hammy_job_extract_user(const struct discord_interaction* event) {
|
||||
if (event->member && event->member->user) return event->member->user->id;
|
||||
if (event->user) return event->user->id;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
hammy_job_t* hammy_job_create(hammy_bot_t* bot, const struct discord_interaction* event) {
|
||||
if (!bot || !bot->client || !event) { return NULL; }
|
||||
|
||||
hammy_job_t* job = (hammy_job_t*)calloc(1, sizeof(*job));
|
||||
if (!job) { return NULL; }
|
||||
|
||||
job->bot = bot;
|
||||
job->id = event->id;
|
||||
job->user = hammy_job_extract_user(event);
|
||||
job->token = hammy_strdup(event->token);
|
||||
job->queuedAt = (int64_t)discord_timestamp(bot->client);
|
||||
|
||||
job->appId = event->application_id ? event->application_id : bot->appId;
|
||||
|
||||
if (!job->token) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
if (event->data && event->data->name) {
|
||||
job->command = hammy_strdup(event->data->name);
|
||||
if (!job->command) {
|
||||
goto fail;
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the top-level options. Subcommand groups nest another options array inside an option
|
||||
// Not handled yet, and worth revisiting before we need one; TODO
|
||||
if (event->data && event->data->options && event->data->options->size > 0) {
|
||||
size_t n = (size_t)event->data->options->size;
|
||||
|
||||
job->args = (hammy_arg_t*)calloc(n, sizeof(*job->args));
|
||||
if (!job->args) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
struct discord_application_command_interaction_data_option* opt = &event->data->options->array[i];
|
||||
|
||||
job->args[i].name = hammy_strdup(opt->name);
|
||||
job->args[i].value = hammy_strdup(opt->value);
|
||||
|
||||
// A NULL value is legitimate for a flag-style option; a NULL name
|
||||
// after a non-NULL source is an allocation failure.
|
||||
if (opt->name && !job->args[i].name) goto fail;
|
||||
if (opt->value && !job->args[i].value) goto fail;
|
||||
|
||||
job->nArgs++;
|
||||
}
|
||||
}
|
||||
|
||||
return job;
|
||||
|
||||
fail:
|
||||
hammy_job_destroy(&job);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bool hammy_job_destroy(hammy_job_t** job) {
|
||||
if (!job || !*job) { return false; }
|
||||
|
||||
hammy_job_t* j = *job;
|
||||
|
||||
for (size_t i = 0; i < j->nArgs; i++) {
|
||||
free(j->args[i].name);
|
||||
free(j->args[i].value);
|
||||
}
|
||||
|
||||
free(j->args);
|
||||
free(j->token);
|
||||
free(j->command);
|
||||
free(j);
|
||||
|
||||
*job = NULL;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* hammy_job_get_arg(const hammy_job_t* job, const char* name) {
|
||||
if (!job || !name) { return NULL; }
|
||||
|
||||
for (size_t i = 0; i < job->nArgs; i++) {
|
||||
if (job->args[i].name && strcmp(job->args[i].name, name) == 0) {
|
||||
return job->args[i].value;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int64_t hammy_job_age_ms(const hammy_job_t* job, struct discord* client) {
|
||||
if (!job || !client) { return 0; }
|
||||
|
||||
return (int64_t)discord_timestamp(client) - job->queuedAt;
|
||||
}
|
||||
|
||||
void hammy_job_reply(const hammy_job_t* job, struct discord* client, const char* title, const char* content, bool isError) {
|
||||
if (!job || !client || !content || !title) { return; }
|
||||
|
||||
// Editing, not creating: every caller is on the deferred path, where the
|
||||
// gateway thread already sent DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE. A second
|
||||
// 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 };
|
||||
|
||||
// Both must outlive the call, so neither can be a compound literal inside
|
||||
// the branch below.
|
||||
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, hammy_refdb_t* refdb) {
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
command->handler(job, client, refdb);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
#include <concord/discord.h>
|
||||
#include <concord/log.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include <hammy/bot.h>
|
||||
#include <hammy/job.h>
|
||||
#include <hammy/pool.h>
|
||||
#include <hammy/worker.h>
|
||||
|
||||
hammy_pool_t* hammy_pool_create(struct discord* client, size_t nWorkers, size_t queueCap) {
|
||||
if (!client) { return NULL; }
|
||||
|
||||
if (nWorkers == 0) { nWorkers = HAMMY_POOL_DEFAULT_WORKERS; }
|
||||
if (queueCap == 0) { queueCap = HAMMY_POOL_DEFAULT_CAPACITY; }
|
||||
|
||||
hammy_pool_t* pool = (hammy_pool_t*)calloc(1, sizeof(*pool));
|
||||
if (!pool) { return NULL; }
|
||||
|
||||
pool->cap = queueCap;
|
||||
pool->jobs = (hammy_job_t**)calloc(pool->cap, sizeof(*pool->jobs));
|
||||
if (!pool->jobs) {
|
||||
goto fail_jobs;
|
||||
}
|
||||
|
||||
pool->workers = (hammy_worker_t*)calloc(nWorkers, sizeof(*pool->workers));
|
||||
if (!pool->workers) {
|
||||
goto fail_workers;
|
||||
}
|
||||
|
||||
if (pthread_mutex_init(&pool->lock, NULL) != 0) {
|
||||
goto fail_mutex;
|
||||
}
|
||||
|
||||
if (pthread_cond_init(&pool->notEmpty, NULL) != 0) {
|
||||
goto fail_cond;
|
||||
}
|
||||
|
||||
// nWorkers counts STARTED threads, so a partial failure below still joins
|
||||
// exactly the ones that exist.
|
||||
for (size_t i = 0; i < nWorkers; i++) {
|
||||
if (!hammy_worker_start(&pool->workers[i], pool, client, (int)i)) {
|
||||
log_error("[pool] only %zu of %zu workers started, bailing!", i, nWorkers);
|
||||
hammy_pool_shutdown(pool);
|
||||
hammy_pool_destroy(&pool);
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
pool->nWorkers++;
|
||||
}
|
||||
|
||||
log_info("[pool] Started %zu workers, queue cap %zu", pool->nWorkers, pool->cap);
|
||||
|
||||
return pool;
|
||||
|
||||
// GOTOs
|
||||
fail_cond:
|
||||
pthread_mutex_destroy(&pool->lock);
|
||||
fail_mutex:
|
||||
free(pool->workers);
|
||||
fail_workers:
|
||||
free(pool->jobs);
|
||||
fail_jobs:
|
||||
free(pool);
|
||||
|
||||
return NULL;
|
||||
|
||||
}
|
||||
|
||||
hammy_push_result_t hammy_pool_push(hammy_pool_t* pool, hammy_job_t* job) {
|
||||
if (!pool || !job) { return HAMMY_PUSH_SHUTDOWN; }
|
||||
|
||||
pthread_mutex_lock(&pool->lock);
|
||||
|
||||
if (pool->shutdown) {
|
||||
pthread_mutex_unlock(&pool->lock);
|
||||
return HAMMY_PUSH_SHUTDOWN;
|
||||
}
|
||||
|
||||
if (pool->count == pool->cap) {
|
||||
pthread_mutex_unlock(&pool->lock);
|
||||
return HAMMY_PUSH_FULL;
|
||||
}
|
||||
|
||||
pool->jobs[pool->tail] = job;
|
||||
pool->tail = (pool->tail + 1) % pool->cap;
|
||||
pool->count++;
|
||||
|
||||
// Signal inside the lock, wakeup cost kinda irrlevant compared to HTTP round trips
|
||||
pthread_cond_signal(&pool->notEmpty);
|
||||
pthread_mutex_unlock(&pool->lock);
|
||||
|
||||
return HAMMY_PUSH_OK;
|
||||
}
|
||||
|
||||
static void hammy_pool_stop(hammy_pool_t* pool, bool drain) {
|
||||
if (!pool) { return; }
|
||||
|
||||
pthread_mutex_lock(&pool->lock);
|
||||
|
||||
if (pool->shutdown) {
|
||||
pthread_mutex_unlock(&pool->lock);
|
||||
return;
|
||||
}
|
||||
|
||||
pool->shutdown = true;
|
||||
|
||||
// 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) {
|
||||
hammy_job_t* job = pool->jobs[pool->head];
|
||||
pool->head = (pool->head + 1) % pool->cap;
|
||||
pool->count--;
|
||||
|
||||
// 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); }
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast (NOT signal), every watier has to see the shutdown flag and exit, not just one.
|
||||
pthread_cond_broadcast(&pool->notEmpty);
|
||||
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++) {
|
||||
hammy_worker_join(&pool->workers[i]);
|
||||
}
|
||||
|
||||
log_info("[pool] Shut down");
|
||||
}
|
||||
|
||||
void hammy_pool_shutdown(hammy_pool_t* pool) {
|
||||
hammy_pool_stop(pool, true);
|
||||
}
|
||||
|
||||
void hammy_pool_shutdown_now(hammy_pool_t* pool) {
|
||||
hammy_pool_stop(pool, false);
|
||||
}
|
||||
|
||||
bool hammy_pool_destroy(hammy_pool_t** pool) {
|
||||
if (!pool || !*pool) { return false; }
|
||||
|
||||
hammy_pool_t* p = *pool;
|
||||
|
||||
hammy_pool_shutdown(p); // No-op if already shut down
|
||||
|
||||
// Anything still queued after the drain is a but - free rather than leak it.
|
||||
while (p->count > 0) {
|
||||
hammy_job_t* job = p->jobs[p->head];
|
||||
p->head = (p->head + 1) % p->cap;
|
||||
p->count--;
|
||||
|
||||
hammy_job_destroy(&job);
|
||||
}
|
||||
|
||||
pthread_cond_destroy(&p->notEmpty);
|
||||
pthread_mutex_destroy(&p->lock);
|
||||
|
||||
free(p->workers);
|
||||
free(p->jobs);
|
||||
free(p);
|
||||
|
||||
*pool = NULL;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void hammy_pool_stats(hammy_pool_t* pool, size_t* outQueued, size_t* outBusy) {
|
||||
if (!pool) { return; }
|
||||
|
||||
pthread_mutex_lock(&pool->lock);
|
||||
if (outBusy) { *outBusy = pool->busy; }
|
||||
if (outQueued) { *outQueued = pool->count; }
|
||||
pthread_mutex_unlock(&pool->lock);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#include <concord/discord.h>
|
||||
#include <concord/discord-internal.h>
|
||||
#include <concord/log.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include <hammy/job.h>
|
||||
#include <hammy/pool.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) {
|
||||
hammy_worker_t* worker = (hammy_worker_t*)arg;
|
||||
hammy_pool_t* pool = worker->pool;
|
||||
|
||||
log_info("[worker %d] Started", worker->id);
|
||||
|
||||
for (;;) {
|
||||
pthread_mutex_lock(&pool->lock);
|
||||
|
||||
// while instead of if, because pthread_cond_wait() can spuriously wake up like an ass
|
||||
while (pool->count == 0 && !pool->shutdown) {
|
||||
pthread_cond_wait(&pool->notEmpty, &pool->lock);
|
||||
}
|
||||
|
||||
if (pool->count == 0 && pool->shutdown) {
|
||||
pthread_mutex_unlock(&pool->lock);
|
||||
break;
|
||||
}
|
||||
|
||||
hammy_job_t* job = pool->jobs[pool->head];
|
||||
pool->head = (pool->head + 1) % pool->cap;
|
||||
pool->count--;
|
||||
pool->busy++;
|
||||
|
||||
pthread_mutex_unlock(&pool->lock);
|
||||
|
||||
// From here, the worker owns the job and is responsible for destroying it.
|
||||
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);
|
||||
|
||||
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);
|
||||
hammy_job_reply(job, worker->clientCopy, "Command Timeout", "Sorry, your command took too long to process and was dropped. Please try again.", true);
|
||||
} else {
|
||||
hammy_job_run(job, worker->clientCopy, worker->refdb);
|
||||
}
|
||||
|
||||
hammy_job_destroy(&job);
|
||||
|
||||
pthread_mutex_lock(&pool->lock);
|
||||
pool->busy--;
|
||||
pthread_mutex_unlock(&pool->lock);
|
||||
}
|
||||
|
||||
log_info("[worker %d] Exiting", worker->id);
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bool hammy_worker_start(hammy_worker_t* worker, hammy_pool_t* pool, struct discord* client, int id) {
|
||||
if (!worker || !pool || !client) { return false; }
|
||||
|
||||
worker->pool = pool;
|
||||
worker->id = id;
|
||||
worker->clientCopy = NULL;
|
||||
worker->started = false;
|
||||
|
||||
// Each thread needs its own client. The queues, pollers and timers are all
|
||||
// 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);
|
||||
if (!worker->clientCopy) {
|
||||
log_error("[worker %d] Failed to clone client", id);
|
||||
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) {
|
||||
log_error("[worker %d] Failed to create thread", id);
|
||||
hammy_worker_cleanup_clone(worker->clientCopy);
|
||||
worker->clientCopy = NULL;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
worker->started = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void hammy_worker_join(hammy_worker_t* worker) {
|
||||
if (!worker) { return; }
|
||||
|
||||
if (worker->started) {
|
||||
pthread_join(worker->thread, NULL);
|
||||
worker->started = false;
|
||||
}
|
||||
|
||||
if (worker->clientCopy) {
|
||||
hammy_worker_cleanup_clone(worker->clientCopy);
|
||||
worker->clientCopy = NULL;
|
||||
}
|
||||
|
||||
if (worker->refdb) {
|
||||
hammy_refdb_close(&worker->refdb);
|
||||
}
|
||||
}
|
||||
+81
-11
@@ -1,22 +1,92 @@
|
||||
#include <string.h>
|
||||
#include <concord/discord.h>
|
||||
#include <concord/log.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
void on_ready(struct discord* client, const struct discord_ready* event) {
|
||||
log_info("Logged in as %s", event->user->username);
|
||||
#include <hammy/bot.h>
|
||||
#include <hammy/pool.h>
|
||||
|
||||
#define HAMMY_CONFIG_PATH "config.json"
|
||||
|
||||
static hammy_bot_t* g_bot = NULL;
|
||||
|
||||
static void hammy_on_sigint(int sig) {
|
||||
(void)sig;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
static void hammy_on_ready(struct discord* client, const struct discord_ready* event) {
|
||||
hammy_bot_t* bot = (hammy_bot_t*)discord_get_data(client);
|
||||
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) {
|
||||
ccord_global_init();
|
||||
|
||||
struct discord* client = discord_config_init("config.json");
|
||||
|
||||
discord_set_on_ready(client, &on_ready);
|
||||
|
||||
discord_run(client);
|
||||
|
||||
discord_cleanup(client);
|
||||
hammy_bot_t* bot = hammy_bot_create();
|
||||
if (!bot) {
|
||||
fprintf(stderr, "could not create bot\n");
|
||||
ccord_global_cleanup();
|
||||
|
||||
return 0;
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
// Token and logging settings both come out of config.json.
|
||||
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);
|
||||
ccord_global_cleanup();
|
||||
|
||||
return ok ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
|
||||
fail:
|
||||
hammy_bot_destroy(&bot);
|
||||
ccord_global_cleanup();
|
||||
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
+53
-33
@@ -31,59 +31,75 @@ extern "C" {
|
||||
#include "priority_queue.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] type the container datatype
|
||||
* @param[in] path the path to the field from the container POV
|
||||
* @param[in] _str the string to check
|
||||
*/
|
||||
#define CONTAINEROF(ptr, type, path) \
|
||||
((type *)((char *)(ptr) - offsetof(type, path)))
|
||||
#define NOT_EMPTY_STR(_str) ((_str) && *(_str))
|
||||
/**
|
||||
* @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
|
||||
* @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
|
||||
*
|
||||
* @param[in] client the Discord client
|
||||
* @param[in] expect the expected outcome
|
||||
* @param[in] code return CCORDcode error code
|
||||
* @param[in] reason for return
|
||||
* @param[in] _client the Discord client
|
||||
* @param[in] _expect the expected outcome
|
||||
* @param[in] _code return CCORDcode error code
|
||||
* @param[in] _reason for return
|
||||
* @return the provided @ref CCORDcode `code` parameter
|
||||
*/
|
||||
#define CCORD_EXPECT(client, expect, code, reason) \
|
||||
#define CCORD_EXPECT(_client, _expect, _code, _reason) \
|
||||
do { \
|
||||
if (!(expect)) { \
|
||||
logmod_log(ERROR, (client)->logger, \
|
||||
"Expected: " #expect " | %s (%s)", \
|
||||
discord_strerror(code, client), \
|
||||
discord_code_as_string(code)); \
|
||||
return code; \
|
||||
if (!(_expect)) { \
|
||||
logmod_log(ERROR, (_client)->logger, \
|
||||
"Expected: " #_expect " | %s (%s)", \
|
||||
discord_strerror(_code, _client), \
|
||||
discord_code_as_string(_code)); \
|
||||
return _code; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/**
|
||||
* @brief log and return `code` if function call doesn't returns @ref CCORD_OK
|
||||
*
|
||||
* @param[in] client the Discord client
|
||||
* @param[in] fn the function call that returns CCORDcode
|
||||
* @param[in] _client the Discord client
|
||||
* @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
|
||||
*/
|
||||
#define CCORD_EXPECT_OK(client, fn) \
|
||||
#define CCORD_DATA_TO_JSON(_client, _type, _body, _params) \
|
||||
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) { \
|
||||
logmod_log(ERROR, (client)->logger, \
|
||||
"Expected: CCORD_OK == " #fn " | %s (%s)", \
|
||||
discord_strerror(code, client), \
|
||||
logmod_log(ERROR, (_client)->logger, \
|
||||
"Expected: CCORD_OK == discord_data_wrap_to_json | " \
|
||||
"%s (%s)", \
|
||||
discord_strerror(code, _client), \
|
||||
discord_code_as_string(code)); \
|
||||
return code; \
|
||||
} \
|
||||
@@ -421,11 +437,9 @@ struct discord_ret_response {
|
||||
/** size of datatype in bytes */
|
||||
size_t size;
|
||||
/** initializer function for datatype fields */
|
||||
void (*init)(void *data);
|
||||
/** populate datatype with JSON values */
|
||||
size_t (*from_json)(const char *json, size_t len, void *data);
|
||||
/** cleanup function for datatype */
|
||||
void (*cleanup)(void *data);
|
||||
struct reflectc_wrap *(*init)(struct reflectc *registry,
|
||||
void *data,
|
||||
struct reflectc_wrap *root);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1191,6 +1205,12 @@ struct discord_cache {
|
||||
* @see discord_init(), discord_config_init(), discord_cleanup()
|
||||
*/
|
||||
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 */
|
||||
struct logmod_logger *logger;
|
||||
/** LogMod loggers table */
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file discord-request.h
|
||||
* @ingroup DiscordInternalREST
|
||||
* @author Cogmasters
|
||||
* @brief Generic macros for initializing a @ref discord_attributes
|
||||
*/
|
||||
|
||||
#ifndef DISCORD_REQUEST_H
|
||||
#define DISCORD_REQUEST_H
|
||||
|
||||
/* helper typedefs for casting */
|
||||
typedef void (*cast_done_typed)(struct discord *,
|
||||
struct discord_response *,
|
||||
const void *);
|
||||
typedef struct reflectc_wrap *(*cast_init)(struct reflectc *,
|
||||
void *,
|
||||
struct reflectc_wrap *);
|
||||
|
||||
/* helper typedef for getting sizeof of `struct discord_ret` common fields */
|
||||
typedef struct {
|
||||
DISCORD_RET_DEFAULT_FIELDS;
|
||||
} discord_ret_default_fields;
|
||||
|
||||
#define _RET_COPY_TYPED(_dest, _src) \
|
||||
do { \
|
||||
memcpy(&(_dest), &(_src), sizeof(discord_ret_default_fields)); \
|
||||
(_dest).has_type = true; \
|
||||
(_dest).done.typed = (cast_done_typed)(_src).done; \
|
||||
(_dest).sync = (_src).sync; \
|
||||
} while (0)
|
||||
|
||||
#define _RET_COPY_TYPELESS(_dest, _src) \
|
||||
do { \
|
||||
memcpy(&(_dest), &(_src), sizeof(discord_ret_default_fields)); \
|
||||
(_dest).has_type = false; \
|
||||
(_dest).done.typeless = (_src).done; \
|
||||
(_dest).sync = (void *)(_src).sync; \
|
||||
} while (0)
|
||||
|
||||
/**
|
||||
* @brief Helper for setting attributes for a specs-generated return struct
|
||||
*
|
||||
* @param[out] _attr @ref discord_attributes handler to be initialized
|
||||
* @param[in] _type datatype of the struct
|
||||
* @param[in] _ret dispatch attributes
|
||||
* @param[in] _reason reason for request (if available)
|
||||
*/
|
||||
#define DISCORD_ATTR_INIT(_attr, _type, _ret, _reason) \
|
||||
do { \
|
||||
(_attr).response.size = sizeof(struct _type); \
|
||||
(_attr).response.init = (cast_init)reflectc_from_##_type; \
|
||||
(_attr).reason = _reason; \
|
||||
if (_ret) _RET_COPY_TYPED((_attr).dispatch, *(_ret)); \
|
||||
} while (0)
|
||||
|
||||
/**
|
||||
* @brief Helper for setting attributes for attributes that don't have a
|
||||
* response object
|
||||
*
|
||||
* @param[out] _attr @ref discord_attributes handler to be initialized
|
||||
* @param[in] _ret dispatch attributes
|
||||
* @param[in] _reason reason for request (if available)
|
||||
*/
|
||||
#define DISCORD_ATTR_BLANK_INIT(_attr, _ret, _reason) \
|
||||
do { \
|
||||
(_attr).reason = _reason; \
|
||||
if (_ret) _RET_COPY_TYPELESS((_attr).dispatch, *(_ret)); \
|
||||
} while (0)
|
||||
|
||||
/**
|
||||
* @brief Helper for initializing attachments ids
|
||||
*
|
||||
* @param[in,out] attchs a @ref discord_attachments to have its IDs initialized
|
||||
*/
|
||||
#define DISCORD_ATTACHMENTS_IDS_INIT(_attchs) \
|
||||
do { \
|
||||
for (int i = 0; i < (_attchs)->size; ++i) { \
|
||||
(_attchs)->array[i].id = (u64snowflake)i; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#endif /* DISCORD_REQUEST_H */
|
||||
@@ -37,8 +37,17 @@ extern "C" {
|
||||
|
||||
/* forward declaration */
|
||||
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-response.h"
|
||||
|
||||
@@ -209,6 +218,12 @@ void discord_shutdown_all(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
|
||||
* @see discord_get_logmod() to configure logging behavior
|
||||
@@ -235,6 +250,15 @@ struct discord *discord_from_json(const char config_file[]);
|
||||
struct discord_config {
|
||||
/** the bot 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 {
|
||||
/** minimum logging 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);
|
||||
|
||||
/** @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
|
||||
* @brief Schedule callbacks to be called in the future
|
||||
* @{ */
|
||||
Vendored
+1582
File diff suppressed because it is too large
Load Diff
@@ -689,6 +689,8 @@ logmod_init(struct logmod *logmod,
|
||||
LOGMOD_API logmod_err
|
||||
logmod_cleanup(struct logmod *logmod)
|
||||
{
|
||||
LOGMOD_EXPECT(logmod != NULL, LOGMOD_BAD_PARAMETER);
|
||||
if (logmod->loggers)
|
||||
memset((void *)logmod->loggers, 0,
|
||||
logmod->real_length * sizeof *logmod->loggers);
|
||||
memset(logmod, 0, sizeof *logmod);
|
||||
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