Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions docs/WEBSOCKET_STATUS.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions gui_interface/ui_communication.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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)
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
22 changes: 20 additions & 2 deletions gui_interface/ui_status.c
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

#include <stdio.h>
#include <inttypes.h>

#include "ui_status.h"

Expand All @@ -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. */
Expand All @@ -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,
Expand All @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions gui_interface/ui_status.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#include "arq.h" /* CALLSIGN_MAX_SIZE */

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion modem/freedv/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions radio_io/radio_io.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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). */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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, &current_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)
Expand Down
8 changes: 8 additions & 0 deletions radio_io/radio_io.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#define RADIO_IO_H_

#include <stdbool.h>
#include <stdint.h>

#define RADIO_TYPE_NONE (-1)
#define RADIO_TYPE_SHM 0
Expand All @@ -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);

Expand Down
Loading