summaryrefslogtreecommitdiff
path: root/firmware/c1224/src
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:15:00 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:15:00 +0000
commitd393e0c30192611d4cac65a5429fd7180ce1f49c (patch)
tree7e47bd930340c75c4802801a81f3bd17c2469d9f /firmware/c1224/src
Initial pushmain
Diffstat (limited to 'firmware/c1224/src')
-rw-r--r--firmware/c1224/src/common/afsk_bitbang.c116
-rw-r--r--firmware/c1224/src/common/banner.c82
-rw-r--r--firmware/c1224/src/common/baud_config.c14
-rw-r--r--firmware/c1224/src/common/board_pins.c45
-rw-r--r--firmware/c1224/src/common/config.c422
-rw-r--r--firmware/c1224/src/common/crystal_steer.c20
-rw-r--r--firmware/c1224/src/common/display.c187
-rw-r--r--firmware/c1224/src/common/hdlc.c192
-rw-r--r--firmware/c1224/src/common/kiss.c98
-rw-r--r--firmware/c1224/src/common/main.c61
-rw-r--r--firmware/c1224/src/common/modem_probe.c38
-rw-r--r--firmware/c1224/src/common/ptt.c31
-rw-r--r--firmware/c1224/src/common/sense.c99
-rw-r--r--firmware/c1224/src/common/status.c78
-rw-r--r--firmware/c1224/src/common/usb_cmd.c268
15 files changed, 1751 insertions, 0 deletions
diff --git a/firmware/c1224/src/common/afsk_bitbang.c b/firmware/c1224/src/common/afsk_bitbang.c
new file mode 100644
index 0000000..7019100
--- /dev/null
+++ b/firmware/c1224/src/common/afsk_bitbang.c
@@ -0,0 +1,116 @@
+/*
+ * Timed NRZI bitbang to TCM3105 TXD/RXD — v1.0-prototype best effort.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "afsk_bitbang.h"
+#include "hdlc.h"
+#include "board_pins.h"
+#include "ptt.h"
+#include "status.h"
+#include "hardware/gpio.h"
+#include "pico/stdlib.h"
+#include <string.h>
+
+#define BIT_US (1000000u / (unsigned)C1224_BITRATE)
+#define TX_BITS_MAX ((HDLC_MAX_INFO + 4) * 10 + 64)
+#define RX_IDLE_MARK true
+
+static uint8_t g_tx_bits[TX_BITS_MAX];
+static volatile bool g_tx_busy;
+static bool g_nrzi_level;
+static hdlc_rx_t g_rx;
+static bool g_rx_nrzi;
+static absolute_time_t g_next_sample;
+static afsk_rx_frame_fn g_rx_cb;
+static uint8_t g_rx_out[HDLC_MAX_INFO];
+
+void afsk_bitbang_init(void)
+{
+ gpio_put(PIN_MODEM_TXD, 1);
+ g_tx_busy = false;
+ g_nrzi_level = true;
+ g_rx_nrzi = RX_IDLE_MARK;
+ hdlc_rx_init(&g_rx);
+ g_rx_cb = NULL;
+ g_next_sample = get_absolute_time();
+}
+
+unsigned afsk_bitrate(void)
+{
+ return (unsigned)C1224_BITRATE;
+}
+
+bool afsk_tx_busy(void)
+{
+ return g_tx_busy;
+}
+
+void afsk_rx_set_callback(afsk_rx_frame_fn fn)
+{
+ g_rx_cb = fn;
+}
+
+static void nrzi_emit(bool data_bit)
+{
+ /* AX.25 NRZI: 0 = transition, 1 = no transition */
+ if (!data_bit) {
+ g_nrzi_level = !g_nrzi_level;
+ }
+ gpio_put(PIN_MODEM_TXD, g_nrzi_level ? 1 : 0);
+}
+
+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(C1224_STATE_SENDING);
+ status_publish();
+ ptt_set(true);
+ sleep_ms(40); /* key-up settle — prototype */
+
+ g_nrzi_level = true;
+ gpio_put(PIN_MODEM_TXD, 1);
+
+ for (size_t i = 0; i < nbits; i++) {
+ nrzi_emit(g_tx_bits[i] != 0);
+ busy_wait_us_32(BIT_US);
+ }
+
+ gpio_put(PIN_MODEM_TXD, 1);
+ sleep_ms(20);
+ ptt_set(false);
+ status_set(C1224_STATE_READY);
+ status_publish();
+ g_tx_busy = false;
+ return true;
+}
+
+void afsk_rx_poll(void)
+{
+ if (g_tx_busy) {
+ return;
+ }
+ absolute_time_t now = get_absolute_time();
+ if (absolute_time_diff_us(g_next_sample, now) < 0) {
+ return;
+ }
+ g_next_sample = delayed_by_us(now, BIT_US);
+
+ bool level = gpio_get(PIN_MODEM_RXD);
+ bool data_bit = (level == g_rx_nrzi); /* 1 = no transition */
+ if (level != g_rx_nrzi) {
+ g_rx_nrzi = level;
+ }
+
+ 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);
+ }
+}
diff --git a/firmware/c1224/src/common/banner.c b/firmware/c1224/src/common/banner.c
new file mode 100644
index 0000000..59eb5dd
--- /dev/null
+++ b/firmware/c1224/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_bitbang.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-c1224 %s", C1224_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/c1224/src/common/baud_config.c b/firmware/c1224/src/common/baud_config.c
new file mode 100644
index 0000000..7b7a37f
--- /dev/null
+++ b/firmware/c1224/src/common/baud_config.c
@@ -0,0 +1,14 @@
+/*
+ * Informational label only — physical rate = stuffed crystal.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "baud_config.h"
+
+static const baud_crystal_config_t cfg = {
+ .label = "c1224",
+};
+
+const baud_crystal_config_t *baud_config_get(void)
+{
+ return &cfg;
+}
diff --git a/firmware/c1224/src/common/board_pins.c b/firmware/c1224/src/common/board_pins.c
new file mode 100644
index 0000000..a199946
--- /dev/null
+++ b/firmware/c1224/src/common/board_pins.c
@@ -0,0 +1,45 @@
+/*
+ * Board GPIO setup — T-Modem-c1224 service Pico.
+ * Copyright (C) 2026 T-Modem contributors
+ * 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)
+{
+ /* Modem digital */
+ gpio_init(PIN_MODEM_TXD);
+ gpio_set_dir(PIN_MODEM_TXD, GPIO_OUT);
+ gpio_put(PIN_MODEM_TXD, 0);
+
+ 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);
+ /* CDT polarity is chip-dependent; no pull forced beyond weak up for floating unpopulated */
+ gpio_pull_up(PIN_MODEM_CDT);
+
+ /* GP14/GP15 mux placeholders — leave unused (single-XTAL v1.0) */
+
+ /* LEDs */
+ 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 for LCD backpack (may be absent — display layer tolerates NACK) */
+ 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/c1224/src/common/config.c b/firmware/c1224/src/common/config.c
new file mode 100644
index 0000000..70f2420
--- /dev/null
+++ b/firmware/c1224/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 'C'
+#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 c1224_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 >= C1224_TEXT_MAX) {
+ n = C1224_TEXT_MAX - 1;
+ }
+ memcpy(dst, src, n);
+ dst[n] = '\0';
+}
+
+void config_load_defaults(void)
+{
+ copy_text(g_cfg.starting, "c1224 is starting");
+ copy_text(g_cfg.ready, "c1224 is ready");
+ copy_text(g_cfg.fault, "c1224 is fault");
+ copy_text(g_cfg.sending, "c1224 is sending");
+ copy_text(g_cfg.signal, "c1224 signal");
+ copy_text(g_cfg.packet, "c1224 packet");
+ copy_text(g_cfg.power, "c1224 power");
+ copy_text(g_cfg.idle, "c1224 idle");
+ copy_text(g_cfg.callsign, "");
+ copy_text(g_cfg.device_id, "c1224");
+ 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,
+ "# c1224.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 c1224_config_t *config_get(void)
+{
+ return &g_cfg;
+}
+
+c1224_config_t *config_mutable(void)
+{
+ return &g_cfg;
+}
+
+bool config_unsolicited_ok(void)
+{
+ return !g_cfg.quiet;
+}
diff --git a/firmware/c1224/src/common/crystal_steer.c b/firmware/c1224/src/common/crystal_steer.c
new file mode 100644
index 0000000..520682c
--- /dev/null
+++ b/firmware/c1224/src/common/crystal_steer.c
@@ -0,0 +1,20 @@
+/*
+ * Crystal policy — v1.0 single-XTAL: physical rate = stuffed crystal.
+ * FW does not switch baud; dual UF2 obsolete; crystal_steer = no-op.
+ * Y1 4.4336 → 1200 · Y2 6.5536 → 2400 · GP14/15 unused (mux abandoned).
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "crystal_steer.h"
+
+static bool g_applied;
+
+void crystal_steer_apply(void)
+{
+ /* No-op: board stuffing (not GPIO mux / not FW baud) selects the crystal. */
+ g_applied = true;
+}
+
+bool crystal_steer_applied(void)
+{
+ return g_applied;
+}
diff --git a/firmware/c1224/src/common/display.c b/firmware/c1224/src/common/display.c
new file mode 100644
index 0000000..e3ac2ab
--- /dev/null
+++ b/firmware/c1224/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 c1224_config_t *cfg = config_get();
+ char l0[17];
+ char l1[17];
+
+ c1224_state_t st = status_get();
+ if (st == C1224_STATE_FAULT) {
+ snprintf(l0, sizeof(l0), "%.16s", cfg->fault);
+ } else if (ptt_get()) {
+ snprintf(l0, sizeof(l0), "%.16s", cfg->sending);
+ } else if (st == C1224_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/c1224/src/common/hdlc.c b/firmware/c1224/src/common/hdlc.c
new file mode 100644
index 0000000..3cee0da
--- /dev/null
+++ b/firmware/c1224/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/c1224/src/common/kiss.c b/firmware/c1224/src/common/kiss.c
new file mode 100644
index 0000000..1d5d0e7
--- /dev/null
+++ b/firmware/c1224/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/c1224/src/common/main.c b/firmware/c1224/src/common/main.c
new file mode 100644
index 0000000..e113f91
--- /dev/null
+++ b/firmware/c1224/src/common/main.c
@@ -0,0 +1,61 @@
+/*
+ * T-Modem-c1224 — USB KISS half-TNC (HDLC on Pico + bitbang to TCM3105).
+ * No softmodem · no transparent Host↔Chip bit bridge · one FW · rate = crystal.
+ * 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_bitbang.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_bitbang_init();
+ usb_cmd_init();
+ afsk_rx_set_callback(usb_cmd_on_rx_frame);
+
+ status_set(C1224_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(C1224_STATE_READY);
+ } else {
+ status_set(C1224_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/c1224/src/common/modem_probe.c b/firmware/c1224/src/common/modem_probe.c
new file mode 100644
index 0000000..33b58e4
--- /dev/null
+++ b/firmware/c1224/src/common/modem_probe.c
@@ -0,0 +1,38 @@
+/*
+ * Offline health check — CDT+RXD readable; crystal_steer no-op applied.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "modem_probe.h"
+#include "config.h"
+#include "board_pins.h"
+#include "crystal_steer.h"
+#include "hardware/gpio.h"
+#include "pico/stdlib.h"
+#include <stdio.h>
+
+modem_probe_result_t modem_probe_run(void)
+{
+ modem_probe_result_t r = {0};
+
+ crystal_steer_apply();
+ r.crystal_driven = crystal_steer_applied();
+
+ (void)gpio_get(PIN_MODEM_CDT);
+ (void)gpio_get(PIN_MODEM_RXD);
+ r.cdt_readable = true;
+ r.rxd_readable = true;
+
+ /* Optional TXD pulse — no CDT reaction required offline */
+ gpio_put(PIN_MODEM_TXD, 1);
+ sleep_ms(1);
+ gpio_put(PIN_MODEM_TXD, 0);
+
+ r.ready = r.crystal_driven && r.cdt_readable && r.rxd_readable;
+
+ if (config_unsolicited_ok()) {
+ printf("PROBE crystal=%d cdt=%d rxd=%d -> %s\r\n",
+ (int)r.crystal_driven, (int)r.cdt_readable, (int)r.rxd_readable,
+ r.ready ? "READY" : "FAULT");
+ }
+ return r;
+}
diff --git a/firmware/c1224/src/common/ptt.c b/firmware/c1224/src/common/ptt.c
new file mode 100644
index 0000000..f54f221
--- /dev/null
+++ b/firmware/c1224/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/c1224/src/common/sense.c b/firmware/c1224/src/common/sense.c
new file mode 100644
index 0000000..6bd33a4
--- /dev/null
+++ b/firmware/c1224/src/common/sense.c
@@ -0,0 +1,99 @@
+/*
+ * Dual sense: CDT (Signal) + RXD packet heuristic (edges + HDLC 0x7E).
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "sense.h"
+#include "board_pins.h"
+#include "config.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;
+
+static void try_flag_sample(void)
+{
+ uint16_t sh = 0;
+ for (int i = 0; i < 16; i++) {
+ bool b = gpio_get(PIN_MODEM_RXD);
+ sh = (uint16_t)((sh << 1) | (b ? 1u : 0u));
+ if ((uint8_t)(sh & 0xFFu) == 0x7Eu) {
+ g_flag = true;
+ return;
+ }
+ sleep_us(833);
+ }
+}
+
+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 c1224_config_t *cfg = config_get();
+
+ bool cdt = gpio_get(PIN_MODEM_CDT);
+ g_signal = cfg->cdt_active_high ? cdt : !cdt;
+
+ 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_flag = false;
+ try_flag_sample();
+ 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/c1224/src/common/status.c b/firmware/c1224/src/common/status.c
new file mode 100644
index 0000000..8d09f22
--- /dev/null
+++ b/firmware/c1224/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 c1224_state_t g_state = C1224_STATE_STARTING;
+
+const char *c1224_state_string(c1224_state_t st)
+{
+ const c1224_config_t *cfg = config_get();
+ switch (st) {
+ case C1224_STATE_STARTING:
+ return cfg->starting;
+ case C1224_STATE_READY:
+ return cfg->ready;
+ case C1224_STATE_FAULT:
+ return cfg->fault;
+ case C1224_STATE_SENDING:
+ return cfg->sending;
+ case C1224_STATE_SIGNAL:
+ return cfg->signal;
+ case C1224_STATE_PACKET:
+ return cfg->packet;
+ default:
+ return cfg->fault;
+ }
+}
+
+void status_set(c1224_state_t st)
+{
+ g_state = st;
+}
+
+c1224_state_t status_get(void)
+{
+ return g_state;
+}
+
+void status_publish(void)
+{
+ const char *s = c1224_state_string(g_state);
+ if (config_unsolicited_ok()) {
+ printf("STATUS %s\r\n", s);
+ }
+ display_update();
+}
+
+void status_tick(void)
+{
+ if (g_state == C1224_STATE_FAULT || g_state == C1224_STATE_STARTING) {
+ display_update();
+ return;
+ }
+
+ c1224_state_t next;
+ if (ptt_get()) {
+ next = C1224_STATE_SENDING;
+ } else if (sense_packet_active()) {
+ next = C1224_STATE_PACKET;
+ } else if (sense_signal_active()) {
+ next = C1224_STATE_SIGNAL;
+ } else {
+ next = C1224_STATE_READY;
+ }
+
+ if (next != g_state) {
+ g_state = next;
+ status_publish();
+ } else {
+ display_update();
+ }
+}
diff --git a/firmware/c1224/src/common/usb_cmd.c b/firmware/c1224/src/common/usb_cmd.c
new file mode 100644
index 0000000..3c412de
--- /dev/null
+++ b/firmware/c1224/src/common/usb_cmd.c
@@ -0,0 +1,268 @@
+/*
+ * 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_bitbang.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 "
+ "cdt_raw=%d rxd_raw=%d bitrate=%u kiss_half_tnc=1\r\n",
+ c1224_state_string(status_get()),
+ (int)ptt_get(),
+ (int)sense_signal_active(),
+ (int)sense_packet_active(),
+ (int)sense_packet_flag(),
+ (int)gpio_get(PIN_MODEM_CDT),
+ (int)gpio_get(PIN_MODEM_RXD),
+ 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("Rate = stuffed crystal (FW bitbang C1224_BITRATE=%u).\r\n",
+ afsk_bitrate());
+ printf("No softmodem · no Host↔Chip bit bridge · no baud switch.\r\n");
+ printf("INI: texts + callsign device_id quiet + sense keys\r\n");
+ return;
+ }
+ if (strcmp(cmd, "ID") == 0) {
+ const c1224_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 : "c1224",
+ 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[C1224_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