summaryrefslogtreecommitdiff
path: root/firmware/s1224/src/common
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:09:58 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:09:58 +0000
commita7362dc14bca1233eb34b41aefebe9cfb22684ac (patch)
treef727c87f92aeb503038deeecc24d3e074dc0197c /firmware/s1224/src/common
Initial pushmain
Diffstat (limited to 'firmware/s1224/src/common')
-rw-r--r--firmware/s1224/src/common/afsk_soft.c224
-rw-r--r--firmware/s1224/src/common/banner.c82
-rw-r--r--firmware/s1224/src/common/baud_config.c19
-rw-r--r--firmware/s1224/src/common/board_pins.c47
-rw-r--r--firmware/s1224/src/common/config.c422
-rw-r--r--firmware/s1224/src/common/crystal_steer.c17
-rw-r--r--firmware/s1224/src/common/display.c187
-rw-r--r--firmware/s1224/src/common/hdlc.c192
-rw-r--r--firmware/s1224/src/common/kiss.c98
-rw-r--r--firmware/s1224/src/common/main.c61
-rw-r--r--firmware/s1224/src/common/modem_probe.c22
-rw-r--r--firmware/s1224/src/common/ptt.c31
-rw-r--r--firmware/s1224/src/common/sense.c72
-rw-r--r--firmware/s1224/src/common/status.c78
-rw-r--r--firmware/s1224/src/common/usb_cmd.c267
15 files changed, 1819 insertions, 0 deletions
diff --git a/firmware/s1224/src/common/afsk_soft.c b/firmware/s1224/src/common/afsk_soft.c
new file mode 100644
index 0000000..3e5d966
--- /dev/null
+++ b/firmware/s1224/src/common/afsk_soft.c
@@ -0,0 +1,224 @@
+/*
+ * Continuous-phase AFSK TX via PWM frequency (mark/space) + NRZI/HDLC.
+ * RX: per-bit Goertzel mark/space on ADC (GP26) when wired.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "afsk_soft.h"
+#include "afsk_profile.h"
+#include "hdlc.h"
+#include "board_pins.h"
+#include "ptt.h"
+#include "status.h"
+#include "hardware/gpio.h"
+#include "hardware/pwm.h"
+#include "hardware/clocks.h"
+#include "pico/stdlib.h"
+#include <string.h>
+#include <math.h>
+
+#define BIT_US (1000000u / (unsigned)S1224_BITRATE)
+#define TX_BITS_MAX ((HDLC_MAX_INFO + 4) * 10 + 64)
+#define PWM_CLKDIV 4.0f
+#define RX_SAMPLES_BIT 8
+#define RX_FS_HZ ((unsigned)S1224_BITRATE * RX_SAMPLES_BIT)
+#define CARRIER_THRESH 50.0f
+
+static uint8_t g_tx_bits[TX_BITS_MAX];
+static volatile bool g_tx_busy;
+static bool g_nrzi_mark;
+static hdlc_rx_t g_rx;
+static afsk_rx_frame_fn g_rx_cb;
+static uint8_t g_rx_out[HDLC_MAX_INFO];
+static uint g_pwm_slice;
+static uint g_pwm_chan;
+static afsk_profile_t g_prof;
+static bool g_carrier;
+
+#if PIN_AF_RX_ADC >= 0
+#include "hardware/adc.h"
+#define RX_GOERTZEL_ENABLE 1
+static float g_rx_samp[RX_SAMPLES_BIT];
+static unsigned g_rx_n;
+static absolute_time_t g_next_adc;
+static bool g_rx_nrzi_mark;
+static float g_coeff_mark;
+static float g_coeff_space;
+#else
+#define RX_GOERTZEL_ENABLE 0
+#endif
+
+static void pwm_tone_hz(uint32_t freq_hz)
+{
+ if (freq_hz < 100) {
+ pwm_set_enabled(g_pwm_slice, false);
+ return;
+ }
+ uint32_t sys = clock_get_hz(clk_sys);
+ uint32_t wrap = (uint32_t)((float)sys / (freq_hz * PWM_CLKDIV)) - 1u;
+ if (wrap < 10) {
+ wrap = 10;
+ }
+ pwm_set_clkdiv(g_pwm_slice, PWM_CLKDIV);
+ pwm_set_wrap(g_pwm_slice, wrap);
+ pwm_set_chan_level(g_pwm_slice, g_pwm_chan, wrap / 2u);
+ pwm_set_enabled(g_pwm_slice, true);
+}
+
+static void nrzi_play_bit(bool data_bit)
+{
+ if (!data_bit) {
+ g_nrzi_mark = !g_nrzi_mark;
+ }
+ pwm_tone_hz(g_nrzi_mark ? g_prof.mark_hz : g_prof.space_hz);
+ busy_wait_us_32(BIT_US);
+}
+
+#if RX_GOERTZEL_ENABLE
+static float goertzel_mag(const float *x, unsigned n, float coeff)
+{
+ float s0 = 0.0f, s1 = 0.0f, s2 = 0.0f;
+ for (unsigned i = 0; i < n; i++) {
+ s0 = x[i] + coeff * s1 - s2;
+ s2 = s1;
+ s1 = s0;
+ }
+ float power = s1 * s1 + s2 * s2 - coeff * s1 * s2;
+ return power < 0.0f ? 0.0f : power;
+}
+
+static float goertzel_coeff(uint32_t tone_hz)
+{
+ float w = 2.0f * (float)M_PI * ((float)tone_hz / (float)RX_FS_HZ);
+ return 2.0f * cosf(w);
+}
+#endif
+
+void afsk_soft_init(void)
+{
+ g_prof = afsk_profile_get();
+ gpio_set_function(PIN_AF_TX, GPIO_FUNC_PWM);
+ g_pwm_slice = pwm_gpio_to_slice_num(PIN_AF_TX);
+ g_pwm_chan = pwm_gpio_to_channel(PIN_AF_TX);
+ pwm_tone_hz(0);
+ g_tx_busy = false;
+ g_nrzi_mark = true;
+ hdlc_rx_init(&g_rx);
+ g_rx_cb = NULL;
+ g_carrier = false;
+
+#if RX_GOERTZEL_ENABLE
+ adc_init();
+ adc_gpio_init(PIN_AF_RX_ADC);
+ adc_select_input((uint)(PIN_AF_RX_ADC - 26));
+ g_rx_n = 0;
+ g_rx_nrzi_mark = true;
+ g_coeff_mark = goertzel_coeff(g_prof.mark_hz);
+ g_coeff_space = goertzel_coeff(g_prof.space_hz);
+ g_next_adc = get_absolute_time();
+#endif
+}
+
+unsigned afsk_bitrate(void)
+{
+ return (unsigned)S1224_BITRATE;
+}
+
+bool afsk_tx_busy(void)
+{
+ return g_tx_busy;
+}
+
+bool afsk_rx_carrier_active(void)
+{
+ return g_carrier;
+}
+
+void afsk_rx_set_callback(afsk_rx_frame_fn fn)
+{
+ g_rx_cb = fn;
+}
+
+bool afsk_tx_frame(const uint8_t *info, size_t info_len)
+{
+ if (g_tx_busy || !info || info_len == 0 || info_len > HDLC_MAX_INFO) {
+ return false;
+ }
+ size_t nbits = hdlc_encode_bits(info, info_len, g_tx_bits, TX_BITS_MAX, 2);
+ if (nbits == 0) {
+ return false;
+ }
+
+ g_tx_busy = true;
+ status_set(S1224_STATE_SENDING);
+ status_publish();
+ ptt_set(true);
+ sleep_ms(40);
+
+ g_nrzi_mark = true;
+ pwm_tone_hz(g_prof.mark_hz);
+
+ for (size_t i = 0; i < nbits; i++) {
+ nrzi_play_bit(g_tx_bits[i] != 0);
+ }
+
+ pwm_tone_hz(0);
+ sleep_ms(20);
+ ptt_set(false);
+ status_set(S1224_STATE_READY);
+ status_publish();
+ g_tx_busy = false;
+ return true;
+}
+
+#if RX_GOERTZEL_ENABLE
+static void rx_process_bit_window(void)
+{
+ float m = goertzel_mag(g_rx_samp, RX_SAMPLES_BIT, g_coeff_mark);
+ float s = goertzel_mag(g_rx_samp, RX_SAMPLES_BIT, g_coeff_space);
+ float peak = m > s ? m : s;
+ g_carrier = peak > CARRIER_THRESH;
+
+ bool tone_mark = (m >= s);
+ bool data_bit = (tone_mark == g_rx_nrzi_mark); /* NRZI: 1 = no transition */
+ if (tone_mark != g_rx_nrzi_mark) {
+ g_rx_nrzi_mark = tone_mark;
+ }
+
+ size_t out_len = 0;
+ if (hdlc_rx_bit(&g_rx, data_bit, g_rx_out, &out_len) && g_rx_cb && out_len > 0) {
+ g_rx_cb(g_rx_out, out_len);
+ }
+}
+
+static void rx_goertzel_sample(void)
+{
+ absolute_time_t now = get_absolute_time();
+ if (absolute_time_diff_us(g_next_adc, now) < 0) {
+ return;
+ }
+ g_next_adc = delayed_by_us(now, 1000000u / RX_FS_HZ);
+
+ uint16_t raw = adc_read();
+ /* Center around mid-scale (12-bit ADC). */
+ g_rx_samp[g_rx_n++] = ((float)raw - 2048.0f) / 2048.0f;
+
+ if (g_rx_n >= RX_SAMPLES_BIT) {
+ g_rx_n = 0;
+ rx_process_bit_window();
+ }
+}
+#else
+static void rx_goertzel_sample(void)
+{
+ /* TX-only boards: leave PIN_AF_RX_ADC as -1. */
+ g_carrier = false;
+}
+#endif
+
+void afsk_rx_poll(void)
+{
+ if (g_tx_busy) {
+ return;
+ }
+ rx_goertzel_sample();
+}
diff --git a/firmware/s1224/src/common/banner.c b/firmware/s1224/src/common/banner.c
new file mode 100644
index 0000000..21d1f3c
--- /dev/null
+++ b/firmware/s1224/src/common/banner.c
@@ -0,0 +1,82 @@
+/*
+ * Fixed boot banner — edit source only; not INI / CDC.
+ * Always printed once after USB CDC ready (main); quiet never suppresses it.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "banner.h"
+#include "version.h"
+#include "afsk_soft.h"
+#include <stdio.h>
+#include <string.h>
+
+/* Fixed CDC column width for centered serial look (40–48). */
+#define BANNER_WIDTH 44
+
+/** Display columns (UTF-8 code points ≈ 1 col; ASCII-safe). */
+static size_t banner_cols(const char *s)
+{
+ size_t cols = 0;
+ while (*s) {
+ unsigned char c = (unsigned char)*s++;
+ if ((c & 0x80u) == 0u) {
+ cols++;
+ } else if ((c & 0xE0u) == 0xC0u) {
+ if (*s) {
+ s++;
+ }
+ cols++;
+ } else if ((c & 0xF0u) == 0xE0u) {
+ if (*s) {
+ s++;
+ }
+ if (*s) {
+ s++;
+ }
+ cols++;
+ } else if ((c & 0xF8u) == 0xF0u) {
+ if (*s) {
+ s++;
+ }
+ if (*s) {
+ s++;
+ }
+ if (*s) {
+ s++;
+ }
+ cols++;
+ } else {
+ cols++;
+ }
+ }
+ return cols;
+}
+
+static void banner_line(const char *text)
+{
+ size_t cols = banner_cols(text);
+ size_t pad = 0;
+ if (cols < BANNER_WIDTH) {
+ pad = (BANNER_WIDTH - cols) / 2u;
+ }
+ for (size_t i = 0; i < pad; i++) {
+ putchar(' ');
+ }
+ fputs(text, stdout);
+ fputs("\r\n", stdout);
+}
+
+void banner_print(void)
+{
+ /* Fixed boot banner — edit source only */
+ char line[BANNER_WIDTH + 8];
+
+ snprintf(line, sizeof(line), "T-Modem-s1224 %s", S1224_FW_VERSION);
+ banner_line(line);
+
+ snprintf(line, sizeof(line), "USB KISS Half-TNC · AFSK %u",
+ (unsigned)afsk_bitrate());
+ banner_line(line);
+
+ banner_line("Non-Profit · GNU GPLv3 · AS-IS");
+ banner_line("https://github.com/ngteq");
+}
diff --git a/firmware/s1224/src/common/baud_config.c b/firmware/s1224/src/common/baud_config.c
new file mode 100644
index 0000000..8a94a19
--- /dev/null
+++ b/firmware/s1224/src/common/baud_config.c
@@ -0,0 +1,19 @@
+#include "baud_config.h"
+#include "afsk_soft.h"
+
+static const baud_profile_config_t cfg = {
+#if S1224_BITRATE == 300
+ .label = "s1224-300",
+#elif S1224_BITRATE == 1200
+ .label = "s1224-1200",
+#elif S1224_BITRATE == 2400
+ .label = "s1224-2400",
+#else
+ .label = "s1224",
+#endif
+};
+
+const baud_profile_config_t *baud_config_get(void)
+{
+ return &cfg;
+}
diff --git a/firmware/s1224/src/common/board_pins.c b/firmware/s1224/src/common/board_pins.c
new file mode 100644
index 0000000..d02ebd0
--- /dev/null
+++ b/firmware/s1224/src/common/board_pins.c
@@ -0,0 +1,47 @@
+/*
+ * Board GPIO setup — T-Modem-s1224 softmodem Pico.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "board_pins.h"
+#include "hardware/gpio.h"
+#include "hardware/i2c.h"
+
+void board_pins_init(void)
+{
+ gpio_init(PIN_AF_TX);
+ gpio_set_dir(PIN_AF_TX, GPIO_OUT);
+ gpio_put(PIN_AF_TX, 0);
+
+#if PIN_AF_RX_ADC >= 0
+ gpio_init(PIN_AF_RX_ADC);
+ gpio_set_dir(PIN_AF_RX_ADC, GPIO_IN);
+#endif
+
+ gpio_init(PIN_MODEM_RXD);
+ gpio_set_dir(PIN_MODEM_RXD, GPIO_IN);
+ gpio_pull_up(PIN_MODEM_RXD);
+
+ gpio_init(PIN_MODEM_CDT);
+ gpio_set_dir(PIN_MODEM_CDT, GPIO_IN);
+ gpio_pull_up(PIN_MODEM_CDT);
+
+ gpio_init(PIN_PTT_DRV);
+ gpio_set_dir(PIN_PTT_DRV, GPIO_OUT);
+ gpio_put(PIN_PTT_DRV, 0);
+
+ gpio_init(PIN_LED_PTT);
+ gpio_set_dir(PIN_LED_PTT, GPIO_OUT);
+ gpio_put(PIN_LED_PTT, 0);
+ gpio_init(PIN_LED_DCD);
+ gpio_set_dir(PIN_LED_DCD, GPIO_OUT);
+ gpio_put(PIN_LED_DCD, 0);
+ gpio_init(PIN_LED_TXRX);
+ gpio_set_dir(PIN_LED_TXRX, GPIO_OUT);
+ gpio_put(PIN_LED_TXRX, 0);
+
+ i2c_init(i2c0, I2C_LCD_BAUDRATE);
+ gpio_set_function(PIN_I2C_SDA, GPIO_FUNC_I2C);
+ gpio_set_function(PIN_I2C_SCL, GPIO_FUNC_I2C);
+ gpio_pull_up(PIN_I2C_SDA);
+ gpio_pull_up(PIN_I2C_SCL);
+}
diff --git a/firmware/s1224/src/common/config.c b/firmware/s1224/src/common/config.c
new file mode 100644
index 0000000..7c8f33c
--- /dev/null
+++ b/firmware/s1224/src/common/config.c
@@ -0,0 +1,422 @@
+/*
+ * INI defaults + flash overlay (last 4 KiB of Pico flash).
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "config.h"
+#include "hardware/flash.h"
+#include "hardware/sync.h"
+#include "pico/stdlib.h"
+#include <stdio.h>
+#include <string.h>
+#include <ctype.h>
+#include <stdlib.h>
+
+#define CFG_MAGIC0 'S'
+#define CFG_MAGIC1 '1'
+#define CFG_MAGIC2 '2'
+#define CFG_MAGIC3 '4'
+#define CFG_VERSION 1
+#define CFG_SECTOR_SIZE 4096
+/* Store config in the last flash sector */
+#define CFG_FLASH_OFFSET (PICO_FLASH_SIZE_BYTES - CFG_SECTOR_SIZE)
+
+typedef struct __attribute__((packed)) {
+ uint8_t magic[4];
+ uint8_t version;
+ uint8_t reserved;
+ uint16_t length;
+ uint16_t crc;
+ /* uint8_t data[length] follows */
+} cfg_hdr_t;
+
+static s1224_config_t g_cfg;
+
+static uint16_t crc16_ccitt(const uint8_t *data, size_t len)
+{
+ uint16_t crc = 0xFFFF;
+ for (size_t i = 0; i < len; i++) {
+ crc ^= (uint16_t)data[i] << 8;
+ for (int b = 0; b < 8; b++) {
+ if (crc & 0x8000) {
+ crc = (uint16_t)((crc << 1) ^ 0x1021);
+ } else {
+ crc <<= 1;
+ }
+ }
+ }
+ return crc;
+}
+
+static void copy_text(char *dst, const char *src)
+{
+ size_t n = strlen(src);
+ if (n >= S1224_TEXT_MAX) {
+ n = S1224_TEXT_MAX - 1;
+ }
+ memcpy(dst, src, n);
+ dst[n] = '\0';
+}
+
+void config_load_defaults(void)
+{
+ copy_text(g_cfg.starting, "s1224 is starting");
+ copy_text(g_cfg.ready, "s1224 is ready");
+ copy_text(g_cfg.fault, "s1224 is fault");
+ copy_text(g_cfg.sending, "s1224 is sending");
+ copy_text(g_cfg.signal, "s1224 signal");
+ copy_text(g_cfg.packet, "s1224 packet");
+ copy_text(g_cfg.power, "s1224 power");
+ copy_text(g_cfg.idle, "s1224 idle");
+ copy_text(g_cfg.callsign, "");
+ copy_text(g_cfg.device_id, "s1224");
+ g_cfg.quiet = true;
+ g_cfg.cdt_active_high = false;
+ g_cfg.packet_hold_ms = 500;
+ g_cfg.packet_edge_min = 4;
+ g_cfg.packet_window_ms = 50;
+}
+
+static char *trim(char *s)
+{
+ while (*s && isspace((unsigned char)*s)) {
+ s++;
+ }
+ char *e = s + strlen(s);
+ while (e > s && isspace((unsigned char)e[-1])) {
+ *--e = '\0';
+ }
+ return s;
+}
+
+static bool parse_bool(const char *v, bool *out)
+{
+ if (!v || !*v) {
+ return false;
+ }
+ if (v[0] == '1' || v[0] == 'y' || v[0] == 'Y' || v[0] == 't' || v[0] == 'T') {
+ *out = true;
+ return true;
+ }
+ if (v[0] == '0' || v[0] == 'n' || v[0] == 'N' || v[0] == 'f' || v[0] == 'F') {
+ *out = false;
+ return true;
+ }
+ return false;
+}
+
+bool config_set_kv(const char *key, const char *value)
+{
+ if (!key || !value) {
+ return false;
+ }
+ char kbuf[32];
+ size_t n = strlen(key);
+ if (n >= sizeof(kbuf)) {
+ return false;
+ }
+ for (size_t i = 0; i <= n; i++) {
+ kbuf[i] = (char)tolower((unsigned char)key[i]);
+ }
+
+ if (strcmp(kbuf, "starting") == 0) {
+ copy_text(g_cfg.starting, value);
+ return true;
+ }
+ if (strcmp(kbuf, "ready") == 0) {
+ copy_text(g_cfg.ready, value);
+ return true;
+ }
+ if (strcmp(kbuf, "fault") == 0) {
+ copy_text(g_cfg.fault, value);
+ return true;
+ }
+ if (strcmp(kbuf, "sending") == 0) {
+ copy_text(g_cfg.sending, value);
+ return true;
+ }
+ if (strcmp(kbuf, "signal") == 0) {
+ copy_text(g_cfg.signal, value);
+ return true;
+ }
+ if (strcmp(kbuf, "packet") == 0) {
+ copy_text(g_cfg.packet, value);
+ return true;
+ }
+ if (strcmp(kbuf, "power") == 0) {
+ copy_text(g_cfg.power, value);
+ return true;
+ }
+ if (strcmp(kbuf, "idle") == 0) {
+ copy_text(g_cfg.idle, value);
+ return true;
+ }
+ if (strcmp(kbuf, "callsign") == 0) {
+ copy_text(g_cfg.callsign, value);
+ return true;
+ }
+ if (strcmp(kbuf, "device_id") == 0) {
+ copy_text(g_cfg.device_id, value);
+ return true;
+ }
+ if (strcmp(kbuf, "quiet") == 0) {
+ bool b;
+ if (!parse_bool(value, &b)) {
+ return false;
+ }
+ g_cfg.quiet = b;
+ return true;
+ }
+ if (strcmp(kbuf, "cdt_active_high") == 0) {
+ bool b;
+ if (!parse_bool(value, &b)) {
+ return false;
+ }
+ g_cfg.cdt_active_high = b;
+ return true;
+ }
+ if (strcmp(kbuf, "packet_hold_ms") == 0) {
+ g_cfg.packet_hold_ms = (uint16_t)atoi(value);
+ return true;
+ }
+ if (strcmp(kbuf, "packet_edge_min") == 0) {
+ g_cfg.packet_edge_min = (uint16_t)atoi(value);
+ return true;
+ }
+ if (strcmp(kbuf, "packet_window_ms") == 0) {
+ g_cfg.packet_window_ms = (uint16_t)atoi(value);
+ return true;
+ }
+ return false;
+}
+
+bool config_get_kv(const char *key, char *buf, size_t buflen)
+{
+ if (!key || !buf || buflen == 0) {
+ return false;
+ }
+ char kbuf[32];
+ size_t n = strlen(key);
+ if (n >= sizeof(kbuf)) {
+ return false;
+ }
+ for (size_t i = 0; i <= n; i++) {
+ kbuf[i] = (char)tolower((unsigned char)key[i]);
+ }
+
+ const char *src = NULL;
+ char num[16];
+ if (strcmp(kbuf, "starting") == 0) {
+ src = g_cfg.starting;
+ } else if (strcmp(kbuf, "ready") == 0) {
+ src = g_cfg.ready;
+ } else if (strcmp(kbuf, "fault") == 0) {
+ src = g_cfg.fault;
+ } else if (strcmp(kbuf, "sending") == 0) {
+ src = g_cfg.sending;
+ } else if (strcmp(kbuf, "signal") == 0) {
+ src = g_cfg.signal;
+ } else if (strcmp(kbuf, "packet") == 0) {
+ src = g_cfg.packet;
+ } else if (strcmp(kbuf, "power") == 0) {
+ src = g_cfg.power;
+ } else if (strcmp(kbuf, "idle") == 0) {
+ src = g_cfg.idle;
+ } else if (strcmp(kbuf, "callsign") == 0) {
+ src = g_cfg.callsign;
+ } else if (strcmp(kbuf, "device_id") == 0) {
+ src = g_cfg.device_id;
+ } else if (strcmp(kbuf, "quiet") == 0) {
+ snprintf(num, sizeof(num), "%d", (int)g_cfg.quiet);
+ src = num;
+ } else if (strcmp(kbuf, "cdt_active_high") == 0) {
+ snprintf(num, sizeof(num), "%d", (int)g_cfg.cdt_active_high);
+ src = num;
+ } else if (strcmp(kbuf, "packet_hold_ms") == 0) {
+ snprintf(num, sizeof(num), "%u", (unsigned)g_cfg.packet_hold_ms);
+ src = num;
+ } else if (strcmp(kbuf, "packet_edge_min") == 0) {
+ snprintf(num, sizeof(num), "%u", (unsigned)g_cfg.packet_edge_min);
+ src = num;
+ } else if (strcmp(kbuf, "packet_window_ms") == 0) {
+ snprintf(num, sizeof(num), "%u", (unsigned)g_cfg.packet_window_ms);
+ src = num;
+ } else {
+ return false;
+ }
+ snprintf(buf, buflen, "%s", src);
+ return true;
+}
+
+static void parse_ini_text(char *text)
+{
+ char *save = NULL;
+ for (char *line = strtok_r(text, "\r\n", &save); line;
+ line = strtok_r(NULL, "\r\n", &save)) {
+ line = trim(line);
+ if (line[0] == '\0' || line[0] == '#' || line[0] == ';') {
+ continue;
+ }
+ if (line[0] == '[') {
+ continue; /* section headers ignored — flat keys */
+ }
+ char *eq = strchr(line, '=');
+ if (!eq) {
+ continue;
+ }
+ *eq = '\0';
+ char *k = trim(line);
+ char *v = trim(eq + 1);
+ (void)config_set_kv(k, v);
+ }
+}
+
+bool config_load_flash(void)
+{
+ const uint8_t *base = (const uint8_t *)(XIP_BASE + CFG_FLASH_OFFSET);
+ const cfg_hdr_t *hdr = (const cfg_hdr_t *)base;
+ if (hdr->magic[0] != CFG_MAGIC0 || hdr->magic[1] != CFG_MAGIC1 ||
+ hdr->magic[2] != CFG_MAGIC2 || hdr->magic[3] != CFG_MAGIC3) {
+ return false;
+ }
+ if (hdr->version != CFG_VERSION) {
+ return false;
+ }
+ if (hdr->length == 0 || hdr->length > (CFG_SECTOR_SIZE - sizeof(cfg_hdr_t) - 1)) {
+ return false;
+ }
+ const uint8_t *data = base + sizeof(cfg_hdr_t);
+ if (crc16_ccitt(data, hdr->length) != hdr->crc) {
+ return false;
+ }
+ static char tmp[CFG_SECTOR_SIZE];
+ if (hdr->length + 1 > sizeof(tmp)) {
+ return false;
+ }
+ memcpy(tmp, data, hdr->length);
+ tmp[hdr->length] = '\0';
+ parse_ini_text(tmp);
+ return true;
+}
+
+static size_t build_ini(char *out, size_t outsz)
+{
+ int n = snprintf(out, outsz,
+ "# s1224.ini — operator texts + terminal + sense\n"
+ "[texts]\n"
+ "starting=%s\n"
+ "ready=%s\n"
+ "fault=%s\n"
+ "sending=%s\n"
+ "signal=%s\n"
+ "packet=%s\n"
+ "power=%s\n"
+ "idle=%s\n"
+ "[terminal]\n"
+ "callsign=%s\n"
+ "device_id=%s\n"
+ "quiet=%d\n"
+ "[sense]\n"
+ "cdt_active_high=%d\n"
+ "packet_hold_ms=%u\n"
+ "packet_edge_min=%u\n"
+ "packet_window_ms=%u\n",
+ g_cfg.starting, g_cfg.ready, g_cfg.fault, g_cfg.sending,
+ g_cfg.signal, g_cfg.packet, g_cfg.power, g_cfg.idle,
+ g_cfg.callsign, g_cfg.device_id,
+ (int)g_cfg.quiet,
+ (int)g_cfg.cdt_active_high,
+ (unsigned)g_cfg.packet_hold_ms,
+ (unsigned)g_cfg.packet_edge_min,
+ (unsigned)g_cfg.packet_window_ms);
+ if (n < 0) {
+ return 0;
+ }
+ if ((size_t)n >= outsz) {
+ return outsz - 1;
+ }
+ return (size_t)n;
+}
+
+bool config_save_flash(void)
+{
+ char body[CFG_SECTOR_SIZE - sizeof(cfg_hdr_t)];
+ size_t len = build_ini(body, sizeof(body));
+ if (len == 0) {
+ return false;
+ }
+ uint8_t sector[CFG_SECTOR_SIZE];
+ memset(sector, 0xFF, sizeof(sector));
+ cfg_hdr_t *hdr = (cfg_hdr_t *)sector;
+ hdr->magic[0] = CFG_MAGIC0;
+ hdr->magic[1] = CFG_MAGIC1;
+ hdr->magic[2] = CFG_MAGIC2;
+ hdr->magic[3] = CFG_MAGIC3;
+ hdr->version = CFG_VERSION;
+ hdr->reserved = 0;
+ hdr->length = (uint16_t)len;
+ hdr->crc = crc16_ccitt((const uint8_t *)body, len);
+ memcpy(sector + sizeof(cfg_hdr_t), body, len);
+
+ uint32_t ints = save_and_disable_interrupts();
+ flash_range_erase(CFG_FLASH_OFFSET, CFG_SECTOR_SIZE);
+ flash_range_program(CFG_FLASH_OFFSET, sector, CFG_SECTOR_SIZE);
+ restore_interrupts(ints);
+ return true;
+}
+
+bool config_erase_flash(void)
+{
+ uint32_t ints = save_and_disable_interrupts();
+ flash_range_erase(CFG_FLASH_OFFSET, CFG_SECTOR_SIZE);
+ restore_interrupts(ints);
+ return true;
+}
+
+void config_dump_ini(void)
+{
+ char body[CFG_SECTOR_SIZE - sizeof(cfg_hdr_t)];
+ size_t len = build_ini(body, sizeof(body));
+ if (len == 0) {
+ printf("(empty)\r\n");
+ return;
+ }
+ /* print line by line for CDC */
+ for (size_t i = 0; i < len; i++) {
+ putchar(body[i]);
+ if (body[i] == '\n') {
+ putchar('\r');
+ }
+ }
+ if (len == 0 || body[len - 1] != '\n') {
+ printf("\r\n");
+ }
+}
+
+void config_init(void)
+{
+ config_load_defaults();
+ bool flash_ok = config_load_flash();
+ /* Extra chatter only when quiet=0 (fixed boot banner is separate / always on). */
+ if (config_unsolicited_ok()) {
+ if (flash_ok) {
+ printf("CONFIG: flash INI loaded\r\n");
+ } else {
+ printf("CONFIG: defaults (no flash overlay)\r\n");
+ }
+ }
+}
+
+const s1224_config_t *config_get(void)
+{
+ return &g_cfg;
+}
+
+s1224_config_t *config_mutable(void)
+{
+ return &g_cfg;
+}
+
+bool config_unsolicited_ok(void)
+{
+ return !g_cfg.quiet;
+}
diff --git a/firmware/s1224/src/common/crystal_steer.c b/firmware/s1224/src/common/crystal_steer.c
new file mode 100644
index 0000000..863b7b1
--- /dev/null
+++ b/firmware/s1224/src/common/crystal_steer.c
@@ -0,0 +1,17 @@
+/*
+ * Softmodem product — no crystal mux / no TCM3105 XTAL steering.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "crystal_steer.h"
+
+static bool g_applied;
+
+void crystal_steer_apply(void)
+{
+ g_applied = true;
+}
+
+bool crystal_steer_applied(void)
+{
+ return g_applied;
+}
diff --git a/firmware/s1224/src/common/display.c b/firmware/s1224/src/common/display.c
new file mode 100644
index 0000000..e2875be
--- /dev/null
+++ b/firmware/s1224/src/common/display.c
@@ -0,0 +1,187 @@
+/*
+ * HD44780 LCD via PCF8574 I2C backpack + USB mirror.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "display.h"
+#include "board_pins.h"
+#include "config.h"
+#include "status.h"
+#include "sense.h"
+#include "ptt.h"
+#include "hardware/i2c.h"
+#include "pico/stdlib.h"
+#include <stdio.h>
+#include <string.h>
+#include <stdint.h>
+
+/* PCF8574 bit map (common backpack): P0=RS P1=RW P2=E P3=BL P4-7=D4-D7 */
+#define LCD_RS 0x01
+#define LCD_RW 0x02
+#define LCD_EN 0x04
+#define LCD_BL 0x08
+
+static bool g_lcd;
+static uint8_t g_addr = I2C_LCD_ADDR_DEFAULT;
+static char g_last_l0[17];
+static char g_last_l1[17];
+
+static bool i2c_write_u8(uint8_t addr, uint8_t v)
+{
+ return i2c_write_blocking(i2c0, addr, &v, 1, false) >= 0;
+}
+
+static bool i2c_probe(uint8_t addr)
+{
+ uint8_t b = 0;
+ return i2c_write_blocking(i2c0, addr, &b, 1, false) >= 0;
+}
+
+static void lcd_write4(uint8_t nibble, uint8_t mode)
+{
+ uint8_t data = (uint8_t)((nibble & 0xF0) | LCD_BL | mode);
+ i2c_write_u8(g_addr, (uint8_t)(data | LCD_EN));
+ sleep_us(1);
+ i2c_write_u8(g_addr, (uint8_t)(data & (uint8_t)~LCD_EN));
+ sleep_us(50);
+}
+
+static void lcd_send(uint8_t value, uint8_t mode)
+{
+ lcd_write4((uint8_t)(value & 0xF0), mode);
+ lcd_write4((uint8_t)((value << 4) & 0xF0), mode);
+}
+
+static void lcd_cmd(uint8_t c)
+{
+ lcd_send(c, 0);
+}
+
+static void lcd_data(uint8_t c)
+{
+ lcd_send(c, LCD_RS);
+}
+
+static void lcd_set_cursor(uint8_t col, uint8_t row)
+{
+ static const uint8_t row_addr[] = {0x00, 0x40, 0x14, 0x54};
+ if (row > 1) {
+ row = 1;
+ }
+ lcd_cmd((uint8_t)(0x80 | (row_addr[row] + col)));
+}
+
+static void lcd_print_line(uint8_t row, const char *s)
+{
+ char buf[17];
+ size_t n = strlen(s);
+ if (n > 16) {
+ n = 16;
+ }
+ memset(buf, ' ', 16);
+ memcpy(buf, s, n);
+ buf[16] = '\0';
+ lcd_set_cursor(0, row);
+ for (int i = 0; i < 16; i++) {
+ lcd_data((uint8_t)buf[i]);
+ }
+}
+
+static bool lcd_init_seq(uint8_t addr)
+{
+ g_addr = addr;
+ sleep_ms(50);
+ lcd_write4(0x30, 0);
+ sleep_ms(5);
+ lcd_write4(0x30, 0);
+ sleep_us(150);
+ lcd_write4(0x30, 0);
+ sleep_us(150);
+ lcd_write4(0x20, 0); /* 4-bit */
+ lcd_cmd(0x28); /* 2 line, 5x8 */
+ lcd_cmd(0x0C); /* display on, cursor off */
+ lcd_cmd(0x06); /* entry mode */
+ lcd_cmd(0x01); /* clear */
+ sleep_ms(2);
+ return true;
+}
+
+void display_init(void)
+{
+ g_lcd = false;
+ g_last_l0[0] = '\0';
+ g_last_l1[0] = '\0';
+
+ uint8_t addrs[] = {0x27, 0x3F};
+ for (unsigned i = 0; i < sizeof(addrs); i++) {
+ if (i2c_probe(addrs[i])) {
+ if (lcd_init_seq(addrs[i])) {
+ g_lcd = true;
+ if (config_unsolicited_ok()) {
+ printf("LCD: HD44780 @ 0x%02X\r\n", addrs[i]);
+ }
+ return;
+ }
+ }
+ }
+ if (config_unsolicited_ok()) {
+ printf("LCD: none — USB status only\r\n");
+ }
+}
+
+bool display_present(void)
+{
+ return g_lcd;
+}
+
+void display_show(const char *line)
+{
+ if (!line) {
+ return;
+ }
+ if (g_lcd) {
+ lcd_print_line(0, line);
+ }
+}
+
+void display_update(void)
+{
+ const s1224_config_t *cfg = config_get();
+ char l0[17];
+ char l1[17];
+
+ s1224_state_t st = status_get();
+ if (st == S1224_STATE_FAULT) {
+ snprintf(l0, sizeof(l0), "%.16s", cfg->fault);
+ } else if (ptt_get()) {
+ snprintf(l0, sizeof(l0), "%.16s", cfg->sending);
+ } else if (st == S1224_STATE_STARTING) {
+ snprintf(l0, sizeof(l0), "%.16s", cfg->starting);
+ } else {
+ /* Power/ready on line 0 */
+ snprintf(l0, sizeof(l0), "%.16s", cfg->ready);
+ }
+
+ /* Line 1: Signal + Packet from sense */
+ bool sig = sense_signal_active();
+ bool pkt = sense_packet_active();
+ if (sig && pkt) {
+ snprintf(l1, sizeof(l1), "%.7s %.7s", cfg->signal, cfg->packet);
+ } else if (sig) {
+ snprintf(l1, sizeof(l1), "%.16s", cfg->signal);
+ } else if (pkt) {
+ snprintf(l1, sizeof(l1), "%.16s", cfg->packet);
+ } else {
+ snprintf(l1, sizeof(l1), "%.16s", cfg->power);
+ }
+
+ if (strcmp(l0, g_last_l0) != 0 || strcmp(l1, g_last_l1) != 0) {
+ strncpy(g_last_l0, l0, sizeof(g_last_l0));
+ strncpy(g_last_l1, l1, sizeof(g_last_l1));
+ g_last_l0[16] = '\0';
+ g_last_l1[16] = '\0';
+ if (g_lcd) {
+ lcd_print_line(0, l0);
+ lcd_print_line(1, l1);
+ }
+ }
+}
diff --git a/firmware/s1224/src/common/hdlc.c b/firmware/s1224/src/common/hdlc.c
new file mode 100644
index 0000000..3cee0da
--- /dev/null
+++ b/firmware/s1224/src/common/hdlc.c
@@ -0,0 +1,192 @@
+/*
+ * HDLC encode/decode — AX.25 bit order (LSB first), FCS-CCITT.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "hdlc.h"
+
+uint16_t hdlc_fcs_update(uint16_t fcs, uint8_t byte)
+{
+ fcs ^= (uint16_t)byte;
+ for (int i = 0; i < 8; i++) {
+ if (fcs & 1u) {
+ fcs = (uint16_t)((fcs >> 1) ^ 0x8408u);
+ } else {
+ fcs >>= 1;
+ }
+ }
+ return fcs;
+}
+
+uint16_t hdlc_fcs_final(uint16_t fcs)
+{
+ return (uint16_t)~fcs;
+}
+
+static bool push_bit(uint8_t *bits, size_t bits_cap, size_t *n, bool v)
+{
+ if (*n >= bits_cap) {
+ return false;
+ }
+ bits[*n] = v ? 1u : 0u;
+ (*n)++;
+ return true;
+}
+
+static bool stuff_bit(uint8_t *bits, size_t bits_cap, size_t *n,
+ unsigned *ones, bool bit)
+{
+ if (!push_bit(bits, bits_cap, n, bit)) {
+ return false;
+ }
+ if (bit) {
+ (*ones)++;
+ if (*ones == 5u) {
+ if (!push_bit(bits, bits_cap, n, false)) {
+ return false;
+ }
+ *ones = 0;
+ }
+ } else {
+ *ones = 0;
+ }
+ return true;
+}
+
+static bool emit_raw_byte(uint8_t *bits, size_t bits_cap, size_t *n, uint8_t b)
+{
+ for (int i = 0; i < 8; i++) {
+ if (!push_bit(bits, bits_cap, n, (b >> i) & 1u)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+static bool emit_stuffed_byte(uint8_t *bits, size_t bits_cap, size_t *n,
+ unsigned *ones, uint8_t b)
+{
+ for (int i = 0; i < 8; i++) {
+ if (!stuff_bit(bits, bits_cap, n, ones, (b >> i) & 1u)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+size_t hdlc_encode_bits(const uint8_t *info, size_t info_len,
+ uint8_t *bits, size_t bits_cap,
+ unsigned trailing_flags)
+{
+ if (!info || !bits || info_len > HDLC_MAX_INFO) {
+ return 0;
+ }
+ size_t n = 0;
+ unsigned ones = 0;
+
+ if (!emit_raw_byte(bits, bits_cap, &n, HDLC_FLAG)) {
+ return 0;
+ }
+
+ uint16_t fcs = HDLC_FCS_INIT;
+ for (size_t i = 0; i < info_len; i++) {
+ fcs = hdlc_fcs_update(fcs, info[i]);
+ if (!emit_stuffed_byte(bits, bits_cap, &n, &ones, info[i])) {
+ return 0;
+ }
+ }
+ fcs = hdlc_fcs_final(fcs);
+ if (!emit_stuffed_byte(bits, bits_cap, &n, &ones, (uint8_t)(fcs & 0xFFu))) {
+ return 0;
+ }
+ if (!emit_stuffed_byte(bits, bits_cap, &n, &ones, (uint8_t)((fcs >> 8) & 0xFFu))) {
+ return 0;
+ }
+
+ ones = 0;
+ unsigned flags = trailing_flags ? trailing_flags : 1u;
+ for (unsigned f = 0; f < flags; f++) {
+ if (!emit_raw_byte(bits, bits_cap, &n, HDLC_FLAG)) {
+ return 0;
+ }
+ }
+ return n;
+}
+
+void hdlc_rx_init(hdlc_rx_t *rx)
+{
+ rx->len = 0;
+ rx->ones = 0;
+ rx->in_frame = false;
+ rx->stuffing = false;
+ rx->byte_acc = 0;
+ rx->bit_count = 0;
+}
+
+bool hdlc_rx_bit(hdlc_rx_t *rx, bool bit, uint8_t *out, size_t *out_len)
+{
+ if (bit) {
+ rx->ones++;
+ } else {
+ if (rx->ones == 5u) {
+ /* stuffed 0 — discard */
+ rx->ones = 0;
+ return false;
+ }
+ if (rx->ones == 6u) {
+ /* flag */
+ if (rx->in_frame && rx->len >= 3) {
+ uint16_t fcs = HDLC_FCS_INIT;
+ for (size_t i = 0; i < rx->len; i++) {
+ fcs = hdlc_fcs_update(fcs, rx->buf[i]);
+ }
+ if (fcs == HDLC_FCS_GOOD) {
+ size_t info_len = rx->len - 2;
+ for (size_t i = 0; i < info_len; i++) {
+ out[i] = rx->buf[i];
+ }
+ *out_len = info_len;
+ rx->len = 0;
+ rx->bit_count = 0;
+ rx->byte_acc = 0;
+ rx->ones = 0;
+ rx->in_frame = true; /* abutting frames */
+ return true;
+ }
+ }
+ rx->len = 0;
+ rx->bit_count = 0;
+ rx->byte_acc = 0;
+ rx->ones = 0;
+ rx->in_frame = true;
+ return false;
+ }
+ if (rx->ones > 6u) {
+ /* abort */
+ rx->in_frame = false;
+ rx->len = 0;
+ rx->bit_count = 0;
+ rx->byte_acc = 0;
+ rx->ones = 0;
+ return false;
+ }
+ rx->ones = 0;
+ }
+
+ if (!rx->in_frame) {
+ return false;
+ }
+
+ rx->byte_acc |= (uint8_t)((bit ? 1u : 0u) << rx->bit_count);
+ rx->bit_count++;
+ if (rx->bit_count >= 8) {
+ if (rx->len < sizeof(rx->buf)) {
+ rx->buf[rx->len++] = rx->byte_acc;
+ } else {
+ rx->in_frame = false;
+ rx->len = 0;
+ }
+ rx->byte_acc = 0;
+ rx->bit_count = 0;
+ }
+ return false;
+}
diff --git a/firmware/s1224/src/common/kiss.c b/firmware/s1224/src/common/kiss.c
new file mode 100644
index 0000000..1d5d0e7
--- /dev/null
+++ b/firmware/s1224/src/common/kiss.c
@@ -0,0 +1,98 @@
+/*
+ * KISS encode/decode.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "kiss.h"
+
+void kiss_rx_init(kiss_rx_t *kx)
+{
+ kx->len = 0;
+ kx->in_frame = false;
+ kx->esc = false;
+}
+
+bool kiss_rx_byte(kiss_rx_t *kx, uint8_t b, uint8_t *out, size_t *out_len)
+{
+ if (b == KISS_FEND) {
+ if (kx->in_frame && kx->len >= 1) {
+ uint8_t cmd = kx->buf[0];
+ if ((cmd & 0x0Fu) == KISS_CMD_DATA && kx->len > 1) {
+ size_t n = kx->len - 1;
+ for (size_t i = 0; i < n; i++) {
+ out[i] = kx->buf[i + 1];
+ }
+ *out_len = n;
+ kx->len = 0;
+ kx->esc = false;
+ kx->in_frame = true;
+ return true;
+ }
+ }
+ kx->len = 0;
+ kx->esc = false;
+ kx->in_frame = true;
+ return false;
+ }
+ if (!kx->in_frame) {
+ return false;
+ }
+ if (kx->esc) {
+ if (b == KISS_TFEND) {
+ b = KISS_FEND;
+ } else if (b == KISS_TFESC) {
+ b = KISS_FESC;
+ }
+ kx->esc = false;
+ } else if (b == KISS_FESC) {
+ kx->esc = true;
+ return false;
+ }
+ if (kx->len < sizeof(kx->buf)) {
+ kx->buf[kx->len++] = b;
+ } else {
+ kx->in_frame = false;
+ kx->len = 0;
+ }
+ return false;
+}
+
+static bool push(uint8_t *dst, size_t cap, size_t *n, uint8_t b)
+{
+ if (*n >= cap) {
+ return false;
+ }
+ dst[(*n)++] = b;
+ return true;
+}
+
+static bool push_esc(uint8_t *dst, size_t cap, size_t *n, uint8_t b)
+{
+ if (b == KISS_FEND) {
+ return push(dst, cap, n, KISS_FESC) && push(dst, cap, n, KISS_TFEND);
+ }
+ if (b == KISS_FESC) {
+ return push(dst, cap, n, KISS_FESC) && push(dst, cap, n, KISS_TFESC);
+ }
+ return push(dst, cap, n, b);
+}
+
+size_t kiss_encode_data(const uint8_t *payload, size_t len,
+ uint8_t *dst, size_t dst_cap)
+{
+ size_t n = 0;
+ if (!push(dst, dst_cap, &n, KISS_FEND)) {
+ return 0;
+ }
+ if (!push_esc(dst, dst_cap, &n, KISS_CMD_DATA)) {
+ return 0;
+ }
+ for (size_t i = 0; i < len; i++) {
+ if (!push_esc(dst, dst_cap, &n, payload[i])) {
+ return 0;
+ }
+ }
+ if (!push(dst, dst_cap, &n, KISS_FEND)) {
+ return 0;
+ }
+ return n;
+}
diff --git a/firmware/s1224/src/common/main.c b/firmware/s1224/src/common/main.c
new file mode 100644
index 0000000..ae17585
--- /dev/null
+++ b/firmware/s1224/src/common/main.c
@@ -0,0 +1,61 @@
+/*
+ * T-Modem-s1224 — USB KISS half-TNC + softmodem AFSK (PWM TX / Goertzel RX).
+ * Three UF2 builds (300 / 1200 / 2400). No TCM3105 chip path.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "pico/stdlib.h"
+#include "board_pins.h"
+#include "status.h"
+#include "ptt.h"
+#include "modem_probe.h"
+#include "usb_cmd.h"
+#include "display.h"
+#include "crystal_steer.h"
+#include "baud_config.h"
+#include "config.h"
+#include "sense.h"
+#include "afsk_soft.h"
+#include "banner.h"
+#include "hardware/gpio.h"
+#include <stdio.h>
+
+int main(void)
+{
+ stdio_init_all();
+ sleep_ms(800);
+
+ board_pins_init();
+ ptt_init();
+ config_init();
+ display_init();
+ sense_init();
+ afsk_soft_init();
+ usb_cmd_init();
+ afsk_rx_set_callback(usb_cmd_on_rx_frame);
+
+ status_set(S1224_STATE_STARTING);
+ status_publish();
+ /* Fixed boot banner — always once after CDC ready; source-only (banner.c). */
+ banner_print();
+
+ modem_probe_result_t probe = modem_probe_run();
+ if (probe.ready) {
+ status_set(S1224_STATE_READY);
+ } else {
+ status_set(S1224_STATE_FAULT);
+ }
+ status_publish();
+
+ while (true) {
+ usb_cmd_poll();
+ afsk_rx_poll();
+ sense_poll();
+
+ gpio_put(PIN_LED_DCD, sense_signal_active() ? 1 : 0);
+ gpio_put(PIN_LED_PTT, ptt_get() ? 1 : 0);
+ gpio_put(PIN_LED_TXRX, sense_packet_active() ? 1 : 0);
+
+ status_tick();
+ sleep_us(50);
+ }
+}
diff --git a/firmware/s1224/src/common/modem_probe.c b/firmware/s1224/src/common/modem_probe.c
new file mode 100644
index 0000000..9624d32
--- /dev/null
+++ b/firmware/s1224/src/common/modem_probe.c
@@ -0,0 +1,22 @@
+/*
+ * Softmodem health — no chip/crystal checks.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "modem_probe.h"
+#include "config.h"
+#include "afsk_soft.h"
+#include <stdio.h>
+
+modem_probe_result_t modem_probe_run(void)
+{
+ modem_probe_result_t r = {0};
+ r.softmodem_ok = true;
+ r.af_tx_pwm = true;
+ r.ready = r.softmodem_ok && (afsk_bitrate() == 300u || afsk_bitrate() == 1200u || afsk_bitrate() == 2400u);
+
+ if (config_unsolicited_ok()) {
+ printf("PROBE softmodem=%d bitrate=%u -> %s\r\n",
+ (int)r.softmodem_ok, afsk_bitrate(), r.ready ? "READY" : "FAULT");
+ }
+ return r;
+}
diff --git a/firmware/s1224/src/common/ptt.c b/firmware/s1224/src/common/ptt.c
new file mode 100644
index 0000000..f54f221
--- /dev/null
+++ b/firmware/s1224/src/common/ptt.c
@@ -0,0 +1,31 @@
+/*
+ * PTT via Pico GPIO — host asserts over USB CDC (Type-A Path B), not softmodem.
+ * PIN_PTT_DRV = keying (required). PIN_LED_PTT = optional/spare TX LED mirror.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "ptt.h"
+#include "board_pins.h"
+#include "hardware/gpio.h"
+
+static bool g_ptt;
+
+void ptt_init(void)
+{
+ gpio_init(PIN_PTT_DRV);
+ gpio_set_dir(PIN_PTT_DRV, GPIO_OUT);
+ gpio_put(PIN_PTT_DRV, 0);
+ g_ptt = false;
+ gpio_put(PIN_LED_PTT, 0);
+}
+
+void ptt_set(bool on)
+{
+ g_ptt = on;
+ gpio_put(PIN_PTT_DRV, on ? 1 : 0);
+ gpio_put(PIN_LED_PTT, on ? 1 : 0);
+}
+
+bool ptt_get(void)
+{
+ return g_ptt;
+}
diff --git a/firmware/s1224/src/common/sense.c b/firmware/s1224/src/common/sense.c
new file mode 100644
index 0000000..376004d
--- /dev/null
+++ b/firmware/s1224/src/common/sense.c
@@ -0,0 +1,72 @@
+/*
+ * Sense for softmodem: carrier from demod stub + optional GP7 heuristic.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "sense.h"
+#include "board_pins.h"
+#include "config.h"
+#include "afsk_soft.h"
+#include "hardware/gpio.h"
+#include "pico/stdlib.h"
+#include <stdint.h>
+
+static bool g_signal;
+static bool g_packet;
+static bool g_flag;
+static absolute_time_t g_packet_until;
+static absolute_time_t g_window_start;
+static uint16_t g_edges;
+static bool g_last_rxd;
+static bool g_hold_valid;
+
+void sense_init(void)
+{
+ g_signal = false;
+ g_packet = false;
+ g_flag = false;
+ g_hold_valid = false;
+ g_window_start = get_absolute_time();
+ g_edges = 0;
+ g_last_rxd = gpio_get(PIN_MODEM_RXD);
+}
+
+void sense_poll(void)
+{
+ const s1224_config_t *cfg = config_get();
+
+ g_signal = afsk_rx_carrier_active();
+
+ bool rxd = gpio_get(PIN_MODEM_RXD);
+ if (rxd != g_last_rxd) {
+ g_edges++;
+ g_last_rxd = rxd;
+ }
+
+ uint32_t win_ms = cfg->packet_window_ms ? cfg->packet_window_ms : 50;
+ if (absolute_time_diff_us(g_window_start, get_absolute_time()) >=
+ (int64_t)win_ms * 1000) {
+ if (g_edges >= cfg->packet_edge_min) {
+ g_packet_until = make_timeout_time_ms(
+ cfg->packet_hold_ms ? cfg->packet_hold_ms : 500);
+ g_hold_valid = true;
+ }
+ g_edges = 0;
+ g_window_start = get_absolute_time();
+ }
+
+ if (g_hold_valid) {
+ if (time_reached(g_packet_until)) {
+ g_hold_valid = false;
+ g_packet = false;
+ g_flag = false;
+ } else {
+ g_packet = true;
+ }
+ } else {
+ g_packet = false;
+ }
+}
+
+bool sense_signal_active(void) { return g_signal; }
+bool sense_packet_active(void) { return g_packet; }
+bool sense_packet_flag(void) { return g_flag; }
diff --git a/firmware/s1224/src/common/status.c b/firmware/s1224/src/common/status.c
new file mode 100644
index 0000000..ca4df5c
--- /dev/null
+++ b/firmware/s1224/src/common/status.c
@@ -0,0 +1,78 @@
+/*
+ * Status from PTT + dual sense; texts from INI.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "status.h"
+#include "config.h"
+#include "display.h"
+#include "ptt.h"
+#include "sense.h"
+#include <stdio.h>
+
+static s1224_state_t g_state = S1224_STATE_STARTING;
+
+const char *s1224_state_string(s1224_state_t st)
+{
+ const s1224_config_t *cfg = config_get();
+ switch (st) {
+ case S1224_STATE_STARTING:
+ return cfg->starting;
+ case S1224_STATE_READY:
+ return cfg->ready;
+ case S1224_STATE_FAULT:
+ return cfg->fault;
+ case S1224_STATE_SENDING:
+ return cfg->sending;
+ case S1224_STATE_SIGNAL:
+ return cfg->signal;
+ case S1224_STATE_PACKET:
+ return cfg->packet;
+ default:
+ return cfg->fault;
+ }
+}
+
+void status_set(s1224_state_t st)
+{
+ g_state = st;
+}
+
+s1224_state_t status_get(void)
+{
+ return g_state;
+}
+
+void status_publish(void)
+{
+ const char *s = s1224_state_string(g_state);
+ if (config_unsolicited_ok()) {
+ printf("STATUS %s\r\n", s);
+ }
+ display_update();
+}
+
+void status_tick(void)
+{
+ if (g_state == S1224_STATE_FAULT || g_state == S1224_STATE_STARTING) {
+ display_update();
+ return;
+ }
+
+ s1224_state_t next;
+ if (ptt_get()) {
+ next = S1224_STATE_SENDING;
+ } else if (sense_packet_active()) {
+ next = S1224_STATE_PACKET;
+ } else if (sense_signal_active()) {
+ next = S1224_STATE_SIGNAL;
+ } else {
+ next = S1224_STATE_READY;
+ }
+
+ if (next != g_state) {
+ g_state = next;
+ status_publish();
+ } else {
+ display_update();
+ }
+}
diff --git a/firmware/s1224/src/common/usb_cmd.c b/firmware/s1224/src/common/usb_cmd.c
new file mode 100644
index 0000000..e4a5fc7
--- /dev/null
+++ b/firmware/s1224/src/common/usb_cmd.c
@@ -0,0 +1,267 @@
+/*
+ * USB CDC multiplexer: KISS binary (0xC0 frames) + ASCII service commands.
+ * Transparent Host↔Chip bit bridge stays abandoned — KISS half-TNC is the exception.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "usb_cmd.h"
+#include "pico/stdlib.h"
+#include "ptt.h"
+#include "status.h"
+#include "config.h"
+#include "sense.h"
+#include "board_pins.h"
+#include "afsk_soft.h"
+#include "kiss.h"
+#include "hardware/gpio.h"
+#include <stdio.h>
+#include <string.h>
+#include <ctype.h>
+
+#define LINE_MAX 96
+
+static char g_line[LINE_MAX];
+static unsigned g_len;
+static kiss_rx_t g_kiss;
+static uint8_t g_kiss_payload[KISS_MAX_PAYLOAD];
+
+static void trim_copy_upper_cmd(char *dst, size_t dstsz, const char *src)
+{
+ while (*src && isspace((unsigned char)*src)) {
+ src++;
+ }
+ size_t i = 0;
+ while (src[i] && i + 1 < dstsz) {
+ dst[i] = (char)toupper((unsigned char)src[i]);
+ i++;
+ }
+ dst[i] = '\0';
+ while (i > 0 && isspace((unsigned char)dst[i - 1])) {
+ dst[--i] = '\0';
+ }
+}
+
+static void handle_line(void)
+{
+ char cmd[LINE_MAX];
+ trim_copy_upper_cmd(cmd, sizeof(cmd), g_line);
+ if (cmd[0] == '\0') {
+ return;
+ }
+
+ if (strcmp(cmd, "PTT ON") == 0 || strcmp(cmd, "PTT 1") == 0) {
+ ptt_set(true);
+ status_tick();
+ printf("OK PTT ON\r\n");
+ return;
+ }
+ if (strcmp(cmd, "PTT OFF") == 0 || strcmp(cmd, "PTT 0") == 0) {
+ ptt_set(false);
+ status_tick();
+ printf("OK PTT OFF\r\n");
+ return;
+ }
+ if (strcmp(cmd, "STATUS") == 0) {
+ printf("STATUS state=%s ptt=%d signal=%d packet=%d flag=%d "
+ "carrier=%d bitrate=%u softmodem=1 kiss_half_tnc=1\r\n",
+ s1224_state_string(status_get()),
+ (int)ptt_get(),
+ (int)sense_signal_active(),
+ (int)sense_packet_active(),
+ (int)sense_packet_flag(),
+ (int)afsk_rx_carrier_active(),
+ afsk_bitrate());
+ return;
+ }
+ if (strcmp(cmd, "HELP") == 0) {
+ printf("Commands:\r\n");
+ printf(" PTT ON | PTT OFF\r\n");
+ printf(" STATUS | HELP | ID\r\n");
+ printf(" QUIET ON|OFF | VERBOSE ON|OFF\r\n");
+ printf(" INI | GET <key> | SET <key>=<value>\r\n");
+ printf(" SAVE | RELOAD | DEFAULTS\r\n");
+ printf("Dual protocol: byte 0xC0 → KISS; line ASCII → service.\r\n");
+ printf("Packet: use KISS-only (any host). Service cmds optional.\r\n");
+ printf("Boot banner always on (source). quiet=1 damps extra ASCII.\r\n");
+ printf("PTT = GP9 (auto during KISS TX; manual PTT ON/OFF kept).\r\n");
+ printf("Softmodem AFSK · S1224_BITRATE=%u (build: 300/1200/2400).\r\n",
+ afsk_bitrate());
+ printf("PWM AF_TX GP6 · ADC AF_RX GP26 · no chip modem · no baud switch.\r\n");
+ printf("INI: texts + callsign device_id quiet + sense keys\r\n");
+ return;
+ }
+ if (strcmp(cmd, "ID") == 0) {
+ const s1224_config_t *cfg = config_get();
+ printf("ID device_id=%s callsign=%s bitrate=%u quiet=%d\r\n",
+ cfg->device_id[0] ? cfg->device_id : "s1224",
+ cfg->callsign,
+ afsk_bitrate(),
+ (int)cfg->quiet);
+ return;
+ }
+ if (strcmp(cmd, "QUIET ON") == 0 || strcmp(cmd, "QUIET 1") == 0) {
+ config_mutable()->quiet = true;
+ printf("OK QUIET ON\r\n");
+ return;
+ }
+ if (strcmp(cmd, "QUIET OFF") == 0 || strcmp(cmd, "QUIET 0") == 0) {
+ config_mutable()->quiet = false;
+ printf("OK QUIET OFF\r\n");
+ return;
+ }
+ if (strcmp(cmd, "VERBOSE ON") == 0 || strcmp(cmd, "VERBOSE 1") == 0) {
+ config_mutable()->quiet = false;
+ printf("OK VERBOSE ON\r\n");
+ return;
+ }
+ if (strcmp(cmd, "VERBOSE OFF") == 0 || strcmp(cmd, "VERBOSE 0") == 0) {
+ config_mutable()->quiet = true;
+ printf("OK VERBOSE OFF\r\n");
+ return;
+ }
+ if (strcmp(cmd, "INI") == 0) {
+ config_dump_ini();
+ return;
+ }
+ if (strcmp(cmd, "SAVE") == 0) {
+ printf(config_save_flash() ? "OK SAVE\r\n" : "ERR SAVE\r\n");
+ return;
+ }
+ if (strcmp(cmd, "RELOAD") == 0) {
+ config_load_defaults();
+ if (config_load_flash()) {
+ printf("OK RELOAD flash\r\n");
+ } else {
+ printf("OK RELOAD defaults (no flash)\r\n");
+ }
+ status_publish();
+ return;
+ }
+ if (strcmp(cmd, "DEFAULTS") == 0) {
+ config_load_defaults();
+ printf("OK DEFAULTS (RAM; SAVE to persist)\r\n");
+ status_publish();
+ return;
+ }
+ if (strncmp(cmd, "GET ", 4) == 0) {
+ char key[32];
+ const char *p = g_line;
+ while (*p && isspace((unsigned char)*p)) {
+ p++;
+ }
+ if (strncasecmp(p, "GET", 3) == 0) {
+ p += 3;
+ }
+ while (*p && isspace((unsigned char)*p)) {
+ p++;
+ }
+ size_t i = 0;
+ while (*p && !isspace((unsigned char)*p) && i + 1 < sizeof(key)) {
+ key[i++] = *p++;
+ }
+ key[i] = '\0';
+ char val[S1224_TEXT_MAX];
+ if (config_get_kv(key, val, sizeof(val))) {
+ printf("OK %s=%s\r\n", key, val);
+ } else {
+ printf("ERR unknown key\r\n");
+ }
+ return;
+ }
+ if (strncmp(cmd, "SET ", 4) == 0) {
+ const char *p = g_line;
+ while (*p && isspace((unsigned char)*p)) {
+ p++;
+ }
+ if (strncasecmp(p, "SET", 3) == 0) {
+ p += 3;
+ }
+ while (*p && isspace((unsigned char)*p)) {
+ p++;
+ }
+ char tmp[LINE_MAX];
+ snprintf(tmp, sizeof(tmp), "%s", p);
+ char *eq = strchr(tmp, '=');
+ if (!eq) {
+ printf("ERR SET key=value\r\n");
+ return;
+ }
+ *eq = '\0';
+ char *k = tmp;
+ char *v = eq + 1;
+ while (*k && isspace((unsigned char)*k)) {
+ k++;
+ }
+ char *ke = k + strlen(k);
+ while (ke > k && isspace((unsigned char)ke[-1])) {
+ *--ke = '\0';
+ }
+ while (*v && isspace((unsigned char)*v)) {
+ v++;
+ }
+ char *ve = v + strlen(v);
+ while (ve > v && isspace((unsigned char)ve[-1])) {
+ *--ve = '\0';
+ }
+ if (config_set_kv(k, v)) {
+ printf("OK SET %s=%s (SAVE to persist)\r\n", k, v);
+ status_publish();
+ } else {
+ printf("ERR unknown key or bad value\r\n");
+ }
+ return;
+ }
+
+ printf("ERR unknown (try HELP)\r\n");
+}
+
+void usb_cmd_on_rx_frame(const uint8_t *info, size_t len)
+{
+ uint8_t frame[KISS_MAX_PAYLOAD + 16];
+ size_t n = kiss_encode_data(info, len, frame, sizeof(frame));
+ for (size_t i = 0; i < n; i++) {
+ putchar_raw((int)frame[i]);
+ }
+}
+
+void usb_cmd_poll(void)
+{
+ int c;
+ while ((c = getchar_timeout_us(0)) != PICO_ERROR_TIMEOUT) {
+ uint8_t b = (uint8_t)c;
+
+ /* KISS path: FEND or mid-frame */
+ if (b == KISS_FEND || g_kiss.in_frame) {
+ size_t plen = 0;
+ if (kiss_rx_byte(&g_kiss, b, g_kiss_payload, &plen)) {
+ if (plen > 0 && !afsk_tx_busy()) {
+ afsk_tx_frame(g_kiss_payload, plen);
+ }
+ }
+ continue;
+ }
+
+ if (c == '\r' || c == '\n') {
+ if (g_len > 0) {
+ g_line[g_len] = '\0';
+ handle_line();
+ g_len = 0;
+ }
+ continue;
+ }
+ if (c == 0x08 || c == 0x7f) {
+ if (g_len > 0) {
+ g_len--;
+ }
+ continue;
+ }
+ if (g_len + 1 < LINE_MAX && c >= 32 && c < 127) {
+ g_line[g_len++] = (char)c;
+ }
+ }
+}
+
+void usb_cmd_init(void)
+{
+ g_len = 0;
+ kiss_rx_init(&g_kiss);
+}
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com