diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9072f49c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +## MercurySkyPulse frequency telemetry authorization + +Codex is authorized to modify this Mercury repository for the MercurySkyPulse +integration task. + +Scope is limited to: + +- conservatively reading the current Hamlib radio frequency inside Mercury; +- caching the value without opening another CAT connection; +- suspending or avoiding polling during PTT and timing-sensitive radio activity; +- publishing frequency and freshness through the documented WebSocket status; +- adding focused unit/contract tests and documentation; +- rebuilding the Mercury runtimes needed by MercurySkyPulse packaging. +- exposing the read-only ARQ TX payload mode and peer RX payload mode through + Mercury's typed UI/WebSocket status for Mercury SkyPulse integration; +- adding focused contract tests and documentation for those read-only ARQ mode + telemetry fields. + +Do not add frequency-setting or radio-mode control in this task. +Do not modify unrelated Mercury behavior. +Do not commit or push unless explicitly authorized. diff --git a/docs/WEBSOCKET_STATUS.md b/docs/WEBSOCKET_STATUS.md new file mode 100644 index 00000000..b500a673 --- /dev/null +++ b/docs/WEBSOCKET_STATUS.md @@ -0,0 +1,33 @@ +# WebSocket status telemetry + +Mercury publishes a flat JSON status object every 500 ms on the optional +`/websocket` endpoint. In addition to modem and connection state, the object +contains read-only radio frequency telemetry: + +- `arq_tx_mode`: the local station's current ARQ payload mode, such as + `DATAC15`, `DATAC4`, `DATAC3`, `DATAC1`, `DATAC17`, or `QAM16C2`. +- `arq_rx_mode`: the peer payload mode currently selected by the local decoder. + TX and RX are independent and may legitimately differ. + +- `radio_frequency_hz`: the last successfully read Hamlib dial frequency, + rounded to integer hertz, or `null` before a successful read and when the + selected radio backend is not Hamlib. +- `radio_frequency_age_ms`: monotonic milliseconds since that successful CAT + read, or `null` when no frequency is available. + +Frequency freshness is independent of the status message cadence. Consumers +should use `radio_frequency_age_ms` to decide whether a cached value is recent +enough for their purpose. + +Mercury uses the already-open Hamlib connection and never opens a telemetry-only +CAT connection. Reads are limited to once every five seconds, are suppressed +while PTT is asserted, wait two seconds after PTT release, and are suspended for +the duration of a connected ARQ session. While reads are suspended, Mercury +continues to publish the cached frequency with an increasing age. + +The telemetry is observational only. The WebSocket command protocol does not +provide frequency-setting or radio-mode controls. + +The payload fields do not report the fixed ARQ control channel. Current Mercury +uses DATAC16 for control frames. They expose the session's independently tracked +payload directions without polling the modem or changing gear-shifting behavior. diff --git a/gui_interface/ui_communication.c b/gui_interface/ui_communication.c index e5dfba6f..44309236 100644 --- a/gui_interface/ui_communication.c +++ b/gui_interface/ui_communication.c @@ -42,6 +42,7 @@ #include "../data_interfaces/tcp_interfaces.h" #include "../common/hermes_log.h" #include "../modem/freedv/modem_stats.h" +#include "../modem/freedv/freedv_api.h" #include "../modem/modem.h" #include "../radio_io/radio_io.h" /* RADIO_TYPE_NONE */ #include "../radio_io/rigctl_parse.h" /* preload_radio_list */ @@ -57,6 +58,20 @@ extern volatile bool shutdown_; #define UI_LOG_TAG "ui-comm" +static const char *ui_arq_mode_name(int mode) +{ + switch (mode) + { + case FREEDV_MODE_DATAC1: return "DATAC1"; + case FREEDV_MODE_DATAC3: return "DATAC3"; + case FREEDV_MODE_DATAC4: return "DATAC4"; + case FREEDV_MODE_DATAC15: return "DATAC15"; + case FREEDV_MODE_DATAC17: return "DATAC17"; + case FREEDV_MODE_QAM16C2: return "QAM16C2"; + default: return ""; + } +} + // Called by the WebSocket server thread when a new UI client connects. // Sets pending flags so the publisher sends device lists and radio list. static void ws_connect_handler(void *user_data) @@ -336,6 +351,10 @@ static void ui_gather_status(ui_ctx_t *ctx, ui_status_t *out) out->sync = snap.connected ? true : false; out->bytes_transmitted = (long)snap.tx_bytes; out->bytes_received = (long)snap.rx_bytes; + snprintf(out->arq_tx_mode, sizeof(out->arq_tx_mode), "%s", + ui_arq_mode_name(snap.payload_mode)); + snprintf(out->arq_rx_mode, sizeof(out->arq_rx_mode), "%s", + ui_arq_mode_name(snap.peer_tx_mode)); } int ctl_status = net_get_status(CTL_TCP_PORT); @@ -348,6 +367,15 @@ static void ui_gather_status(ui_ctx_t *ctx, ui_status_t *out) out->tx_peak_dbfs = modem_get_tx_peak_dbfs(); out->waterfall_enabled = ctx->waterfall_enabled ? true : false; + + /* A connected ARQ session is timing-sensitive even between PTT bursts. + * Continue publishing the cached value and its increasing age, but do not + * issue CAT reads until the link is idle. */ + bool allow_frequency_poll = !(have_snap && snap.initialized && + (snap.connected || snap.trx == 1)); + out->radio_frequency_valid = + radio_io_get_frequency(allow_frequency_poll, &out->radio_frequency_hz, + &out->radio_frequency_age_ms); } /* Copy the latest gathered status out for the embedded UI. Returns false diff --git a/gui_interface/ui_status.c b/gui_interface/ui_status.c index 254283cc..7a09d979 100644 --- a/gui_interface/ui_status.c +++ b/gui_interface/ui_status.c @@ -5,6 +5,7 @@ */ #include +#include #include "ui_status.h" @@ -13,6 +14,15 @@ int ui_status_to_json(const ui_status_t *st, char *buf, size_t buflen) if (!st || !buf || buflen == 0) return -1; + char frequency[32] = "null"; + char age[32] = "null"; + if (st->radio_frequency_valid) + { + snprintf(frequency, sizeof(frequency), "%" PRIu64, + st->radio_frequency_hz); + snprintf(age, sizeof(age), "%" PRIu64, st->radio_frequency_age_ms); + } + /* Field order and formatting are the established wire format — remote * clients parse this. Keep it byte-for-byte stable; test_ui_status.c * fails if it drifts. */ @@ -29,7 +39,12 @@ int ui_status_to_json(const ui_status_t *st, char *buf, size_t buflen) "\"bytes_received\":%ld," "\"tx_gain_db\":%.1f," "\"tx_peak_dbfs\":%.1f," - "\"waterfall\":%s}", + "\"waterfall\":%s," + "\"arq_tx_mode\":\"%s\"," + "\"arq_rx_mode\":\"%s\"," + "\"radio_frequency_hz\":" + "%s," + "\"radio_frequency_age_ms\":%s}", st->bitrate_bps, st->snr_db, st->user_callsign, @@ -41,7 +56,10 @@ int ui_status_to_json(const ui_status_t *st, char *buf, size_t buflen) st->bytes_received, (double)st->tx_gain_db, (double)st->tx_peak_dbfs, - st->waterfall_enabled ? "true" : "false"); + st->waterfall_enabled ? "true" : "false", + st->arq_tx_mode, + st->arq_rx_mode, + frequency, age); if (n < 0 || (size_t)n >= buflen) return -1; diff --git a/gui_interface/ui_status.h b/gui_interface/ui_status.h index 672cec2f..45a97b6a 100644 --- a/gui_interface/ui_status.h +++ b/gui_interface/ui_status.h @@ -20,6 +20,7 @@ #include #include +#include #include "arq.h" /* CALLSIGN_MAX_SIZE */ @@ -36,6 +37,11 @@ typedef struct { float tx_gain_db; float tx_peak_dbfs; bool waterfall_enabled; + char arq_tx_mode[16]; /* local payload mode, e.g. DATAC3 */ + char arq_rx_mode[16]; /* peer payload mode used by local decoder */ + bool radio_frequency_valid; + uint64_t radio_frequency_hz; + uint64_t radio_frequency_age_ms; } ui_status_t; /* Render the snapshot as the status JSON remote clients already expect. diff --git a/modem/freedv/Makefile b/modem/freedv/Makefile index a6393aa2..5f8ac215 100644 --- a/modem/freedv/Makefile +++ b/modem/freedv/Makefile @@ -17,7 +17,7 @@ OBJS_COMMON = freedv_api.o ofdm.o freedv_fsk.o cohpsk.o fsk.o kiss_fft.o freedv_ all: freedv_data_tx freedv_data_rx freedv_data_raw_tx freedv_data_raw_rx $(LIBNAME): $(OBJS_COMMON) - ar rcs $(LIBNAME) $(OBJS_COMMON) + $(AR) rcs $(LIBNAME) $(OBJS_COMMON) freedv_data_tx: freedv_data_tx.o $(LIBNAME) diff --git a/radio_io/radio_io.c b/radio_io/radio_io.c index 2b0ef073..493efdb4 100644 --- a/radio_io/radio_io.c +++ b/radio_io/radio_io.c @@ -37,6 +37,8 @@ #include "../common/hermes_log.h" #define RADIO_LOG_TAG "radio-io" +#define FREQUENCY_POLL_INTERVAL_MS 5000U +#define FREQUENCY_POST_PTT_QUIET_MS 2000U #ifdef HAVE_HERMES_SHM #include "sbitx_io.h" @@ -91,6 +93,12 @@ static int g_radio_type = RADIO_TYPE_NONE; static char g_device_path[256] = {0}; static int g_hamlib_log_level = 0; /* hamlib debug level (0-6) */ static int g_serial_speed = 0; /* serial baud rate (0 = hamlib default) */ +static bool g_ptt_active = false; +static uint64_t g_last_ptt_off_ms = 0; +static uint64_t g_frequency_hz = 0; +static uint64_t g_frequency_read_ms = 0; +static uint64_t g_frequency_attempt_ms = 0; +static bool g_frequency_valid = false; #ifdef HAVE_HAMLIB static RIG *radio = NULL; #endif @@ -106,6 +114,12 @@ int radio_io_init(int radio_type, const char *device_path, int hamlib_log_level, radio_type, device_path && device_path[0] ? device_path : "(none)", hamlib_log_level, serial_speed); g_serial_speed = serial_speed; + g_ptt_active = false; + g_last_ptt_off_ms = hermes_uptime_ms(); + g_frequency_hz = 0; + g_frequency_read_ms = 0; + g_frequency_attempt_ms = 0; + g_frequency_valid = false; /* Validate/clamp hamlib_log_level so that g_hamlib_log_level always * reflects a value that can actually be applied (valid range 0-6). */ @@ -219,6 +233,8 @@ int radio_io_init(int radio_type, const char *device_path, int hamlib_log_level, #else HLOGE(RADIO_LOG_TAG, "HAMLIB support not compiled in. Install libhamlib-dev and rebuild."); g_radio_type = RADIO_TYPE_NONE; + g_ptt_active = false; + g_frequency_valid = false; pthread_mutex_unlock(&g_radio_mutex); return -1; #endif @@ -270,10 +286,12 @@ bool radio_io_enabled(void) void radio_io_key_on(void) { pthread_mutex_lock(&g_radio_mutex); + g_ptt_active = true; if (g_radio_type == RADIO_TYPE_NONE) { HLOGD(RADIO_LOG_TAG, "key_on called but radio is disabled, ignoring"); + g_ptt_active = false; pthread_mutex_unlock(&g_radio_mutex); return; } @@ -317,6 +335,8 @@ void radio_io_key_off(void) if (g_radio_type == RADIO_TYPE_NONE) { HLOGD(RADIO_LOG_TAG, "key_off called but radio is disabled, ignoring"); + g_ptt_active = false; + g_last_ptt_off_ms = hermes_uptime_ms(); pthread_mutex_unlock(&g_radio_mutex); return; } @@ -334,6 +354,8 @@ void radio_io_key_off(void) radio_cmd(sbitx_connector, srv_cmd, response); HLOGD(RADIO_LOG_TAG, "PTT OFF via SHM"); + g_ptt_active = false; + g_last_ptt_off_ms = hermes_uptime_ms(); pthread_mutex_unlock(&g_radio_mutex); return; } @@ -350,7 +372,71 @@ void radio_io_key_off(void) } #endif + g_ptt_active = false; + g_last_ptt_off_ms = hermes_uptime_ms(); + + pthread_mutex_unlock(&g_radio_mutex); +} + +bool radio_io_get_frequency(bool allow_poll, uint64_t *frequency_hz, + uint64_t *age_ms) +{ + if (!frequency_hz || !age_ms) + return false; + + pthread_mutex_lock(&g_radio_mutex); + uint64_t now = hermes_uptime_ms(); + +#ifdef HAVE_HAMLIB + bool poll_due = g_frequency_attempt_ms == 0 || + now - g_frequency_attempt_ms >= FREQUENCY_POLL_INTERVAL_MS; + bool post_ptt_quiet = + now - g_last_ptt_off_ms < FREQUENCY_POST_PTT_QUIET_MS; + if (allow_poll && poll_due && !g_ptt_active && !post_ptt_quiet && + g_radio_type > 0 && radio) + { + freq_t freq = 0; + g_frequency_attempt_ms = now; + int ret = rig_get_freq(radio, RIG_VFO_CURR, &freq); + if (ret != RIG_OK) + { + /* Some Hamlib backends support PTT but reject RIG_VFO_CURR for + * reads. Resolve the active VFO through the same open rig session + * and retry that exact VFO; do not probe or change VFOs. */ + vfo_t current_vfo = RIG_VFO_NONE; + int vfo_ret = rig_get_vfo(radio, ¤t_vfo); + if (vfo_ret == RIG_OK && current_vfo != RIG_VFO_NONE && + current_vfo != RIG_VFO_CURR) + { + freq = 0; + ret = rig_get_freq(radio, current_vfo, &freq); + } + } + now = hermes_uptime_ms(); + if (ret == RIG_OK && freq > 0) + { + g_frequency_hz = (uint64_t)(freq + 0.5); + g_frequency_read_ms = now; + g_frequency_valid = true; + } + else if (ret != RIG_OK) + { + HLOGD(RADIO_LOG_TAG, "Frequency read failed (model %d): %s", + g_radio_type, rigerror(ret)); + } + } +#else + (void)allow_poll; +#endif + + bool valid = g_frequency_valid; + if (valid) + { + *frequency_hz = g_frequency_hz; + *age_ms = now >= g_frequency_read_ms ? now - g_frequency_read_ms : 0; + } pthread_mutex_unlock(&g_radio_mutex); + return valid; } void radio_io_list_models(void) diff --git a/radio_io/radio_io.h b/radio_io/radio_io.h index f3decc5c..e8b45f93 100644 --- a/radio_io/radio_io.h +++ b/radio_io/radio_io.h @@ -22,6 +22,7 @@ #define RADIO_IO_H_ #include +#include #define RADIO_TYPE_NONE (-1) #define RADIO_TYPE_SHM 0 @@ -46,6 +47,13 @@ void radio_io_key_on(void); /* Key transmitter off (PTT OFF). */ void radio_io_key_off(void); +/* Return the last successfully read Hamlib frequency and its age. When + * allow_poll is true, this may refresh the cache if the rig has been idle long + * enough; it never opens another CAT connection. Polling is suppressed while + * PTT is active and briefly after PTT release. */ +bool radio_io_get_frequency(bool allow_poll, uint64_t *frequency_hz, + uint64_t *age_ms); + /* List all hamlib-supported radio models and exit. */ void radio_io_list_models(void); diff --git a/tests/gui_interface/test_ui_status.c b/tests/gui_interface/test_ui_status.c index c73786cd..cb934461 100644 --- a/tests/gui_interface/test_ui_status.c +++ b/tests/gui_interface/test_ui_status.c @@ -33,6 +33,11 @@ static ui_status_t sample(void) st.tx_gain_db = -15.0f; st.tx_peak_dbfs = -3.5f; st.waterfall_enabled = true; + snprintf(st.arq_tx_mode, sizeof(st.arq_tx_mode), "DATAC3"); + snprintf(st.arq_rx_mode, sizeof(st.arq_rx_mode), "DATAC4"); + st.radio_frequency_valid = true; + st.radio_frequency_hz = 14074000; + st.radio_frequency_age_ms = 750; return st; } @@ -56,7 +61,11 @@ void test_status_json_is_byte_exact(void) "\"bytes_received\":128," "\"tx_gain_db\":-15.0," "\"tx_peak_dbfs\":-3.5," - "\"waterfall\":true}"; + "\"waterfall\":true," + "\"arq_tx_mode\":\"DATAC3\"," + "\"arq_rx_mode\":\"DATAC4\"," + "\"radio_frequency_hz\":14074000," + "\"radio_frequency_age_ms\":750}"; TEST_ASSERT_EQUAL_STRING(expect, buf); TEST_ASSERT_EQUAL_INT((int)strlen(expect), n); @@ -110,6 +119,27 @@ void test_null_arguments_are_rejected(void) TEST_ASSERT_EQUAL_INT(-1, ui_status_to_json(&st, buf, 0)); } +void test_unavailable_frequency_is_explicit_null(void) +{ + ui_status_t st = sample(); + st.radio_frequency_valid = false; + char buf[512]; + TEST_ASSERT_TRUE(ui_status_to_json(&st, buf, sizeof(buf)) > 0); + TEST_ASSERT_NOT_NULL(strstr(buf, "\"radio_frequency_hz\":null")); + TEST_ASSERT_NOT_NULL(strstr(buf, "\"radio_frequency_age_ms\":null")); +} + +void test_arq_payload_modes_are_independent_read_only_fields(void) +{ + ui_status_t st = sample(); + char buf[512]; + snprintf(st.arq_tx_mode, sizeof(st.arq_tx_mode), "DATAC1"); + snprintf(st.arq_rx_mode, sizeof(st.arq_rx_mode), "DATAC15"); + TEST_ASSERT_TRUE(ui_status_to_json(&st, buf, sizeof(buf)) > 0); + TEST_ASSERT_NOT_NULL(strstr(buf, "\"arq_tx_mode\":\"DATAC1\"")); + TEST_ASSERT_NOT_NULL(strstr(buf, "\"arq_rx_mode\":\"DATAC15\"")); +} + int main(void) { UNITY_BEGIN(); @@ -118,5 +148,7 @@ int main(void) RUN_TEST(test_booleans_render_as_json_literals); RUN_TEST(test_truncation_is_reported_not_emitted); RUN_TEST(test_null_arguments_are_rejected); + RUN_TEST(test_unavailable_frequency_is_explicit_null); + RUN_TEST(test_arq_payload_modes_are_independent_read_only_fields); return UNITY_END(); }