summaryrefslogtreecommitdiff
path: root/firmware/c1224
diff options
context:
space:
mode:
Diffstat (limited to 'firmware/c1224')
-rw-r--r--firmware/c1224/CMakeLists.txt57
-rw-r--r--firmware/c1224/README.md116
-rw-r--r--firmware/c1224/config/default.ini25
-rw-r--r--firmware/c1224/include/afsk_bitbang.h30
-rw-r--r--firmware/c1224/include/banner.h12
-rw-r--r--firmware/c1224/include/baud_config.h14
-rw-r--r--firmware/c1224/include/board_pins.h52
-rw-r--r--firmware/c1224/include/config.h58
-rw-r--r--firmware/c1224/include/crystal_steer.h10
-rw-r--r--firmware/c1224/include/display.h17
-rw-r--r--firmware/c1224/include/hdlc.h49
-rw-r--r--firmware/c1224/include/kiss.h34
-rw-r--r--firmware/c1224/include/modem_probe.h15
-rw-r--r--firmware/c1224/include/ptt.h10
-rw-r--r--firmware/c1224/include/sense.h19
-rw-r--r--firmware/c1224/include/status.h23
-rw-r--r--firmware/c1224/include/usb_cmd.h11
-rw-r--r--firmware/c1224/include/version.h11
-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
33 files changed, 2314 insertions, 0 deletions
diff --git a/firmware/c1224/CMakeLists.txt b/firmware/c1224/CMakeLists.txt
new file mode 100644
index 0000000..8b5db51
--- /dev/null
+++ b/firmware/c1224/CMakeLists.txt
@@ -0,0 +1,57 @@
+# T-Modem-c1224 Pico firmware — ONE UF2 · GPLv3 · AS-IS
+# USB KISS half-TNC (HDLC on Pico + bitbang GP6/GP7). No softmodem.
+cmake_minimum_required(VERSION 3.13)
+
+if(NOT DEFINED ENV{PICO_SDK_PATH} AND NOT DEFINED PICO_SDK_PATH)
+ message(FATAL_ERROR "Set PICO_SDK_PATH to the Raspberry Pi Pico SDK root")
+endif()
+if(DEFINED ENV{PICO_SDK_PATH} AND NOT DEFINED PICO_SDK_PATH)
+ set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
+endif()
+
+include(${PICO_SDK_PATH}/external/pico_sdk_import.cmake)
+
+project(c1224_firmware C CXX ASM)
+set(CMAKE_C_STANDARD 11)
+set(CMAKE_CXX_STANDARD 17)
+
+pico_sdk_init()
+
+# Physical rate = stuffed crystal. Bitbang must match: 1200 (Y1) or 2400 (Y2).
+if(NOT DEFINED C1224_BITRATE)
+ set(C1224_BITRATE 1200)
+endif()
+
+add_executable(c1224
+ src/common/main.c
+ src/common/status.c
+ src/common/ptt.c
+ src/common/modem_probe.c
+ src/common/usb_cmd.c
+ src/common/display.c
+ src/common/board_pins.c
+ src/common/crystal_steer.c
+ src/common/baud_config.c
+ src/common/config.c
+ src/common/sense.c
+ src/common/hdlc.c
+ src/common/kiss.c
+ src/common/afsk_bitbang.c
+ src/common/banner.c
+)
+
+target_include_directories(c1224 PRIVATE include)
+target_compile_definitions(c1224 PRIVATE C1224_BITRATE=${C1224_BITRATE})
+target_link_libraries(c1224
+ pico_stdlib
+ hardware_gpio
+ hardware_i2c
+ hardware_flash
+ hardware_sync
+)
+
+pico_enable_stdio_usb(c1224 1)
+pico_enable_stdio_uart(c1224 0)
+pico_add_extra_outputs(c1224)
+
+pico_set_binary_type(c1224 default)
diff --git a/firmware/c1224/README.md b/firmware/c1224/README.md
new file mode 100644
index 0000000..298dc64
--- /dev/null
+++ b/firmware/c1224/README.md
@@ -0,0 +1,116 @@
+# T-Modem-c1224 — Pico USB KISS half-TNC firmware
+
+**AS-IS** · GPLv3 (repo `LICENSE`) · no support, no warranty · **v1.0-prototype**.
+
+**One** UF2 for Raspberry Pi Pico (RP2040). Chip does air AFSK; Pico is **USB KISS half-TNC** (HDLC + bitbang to TCM3105 TXD/RXD) plus display / PTT / sense.
+
+| Owns | Does **not** |
+|------|----------------|
+| HDLC TX/RX (flags, bit-stuff, FCS-CCITT) | Softmodem / AFSK DSP |
+| Bitbang GP6 TXD / GP7 RXD at crystal rate | Transparent Host↔Chip bit bridge |
+| USB CDC: KISS `0xC0` + ASCII service | Baud switch / XTAL mux |
+| Auto PTT GP9 during KISS TX · `PTT ON`/`OFF` | Dual baud UF2 builds |
+| Display / INI texts · CDT Signal · packet sense · fixed boot banner | INI/CDC boot-banner toggle |
+
+## Build
+
+Requires [Pico SDK](https://github.com/raspberrypi/pico-sdk) and `arm-none-eabi-gcc`.
+
+```bash
+export PICO_SDK_PATH=/path/to/pico-sdk # or repo .deps/pico-sdk
+cd firmware/c1224
+mkdir -p build && cd build
+cmake .. -DC1224_BITRATE=1200 # or 2400 if Y2 stuffed
+make -j$(nproc) c1224
+# → c1224.uf2 c1224.elf
+```
+
+`C1224_BITRATE` must match the stuffed crystal (**Y1 4.4336 → 1200**, **Y2 6.5536 → 2400**). Default **1200**. Same source tree; rebuild if field board is 2400.
+
+### Flash
+
+1. Hold **BOOTSEL**, plug USB (Path B — flash/service; board power = **12 V** barrel).
+2. Copy `c1224.uf2` to RPI-RP2 (or `picotool load -f c1224.uf2`).
+3. Host opens `/dev/ttyACM*` (USB CDC).
+
+## Any KISS terminal (standalone — no MAX25)
+
+**MAX25-Stack is not required.** No proprietary host protocol. **All terminals should work insofar as applicable** — any client that speaks **KISS and/or ASCII service** over a raw serial port. Optional convenience only: max25d device **`tmodem`** / `max25-terminal`.
+
+How-to: flash UF2 → plug USB → open `/dev/ttyACM*` → KISS `0xC0` or ASCII `HELP`.
+
+| Step | Detail |
+|------|--------|
+| Device | `/dev/ttyACM*` (Linux) / equivalent CDC ACM |
+| Framing | Raw **8N1** · binary **KISS** (`0xC0` … `0xC0`) · DATA cmd `0x00` |
+| Packet path | Send/receive KISS frames only — do **not** interleave ASCII while mid-frame |
+| Service path | Optional line ASCII (`STATUS`, `PTT`, …) when **not** inside a KISS frame |
+| Boot | Fixed source banner **always** once after CDC ready · `quiet=1` damps extra STATUS/CONFIG ASCII only |
+
+Examples: **kissattach** / ax25 tools, **kissutil**, Dire Wolf serial KISS, custom terminal, or optionally **max25-terminal** via **`tmodem`**.
+
+```bash
+# Example: open CDC raw and use KISS (tool-dependent)
+# stty -F /dev/ttyACM0 raw -echo
+# kissutil -p /dev/ttyACM0
+# # or: kissattach /dev/ttyACM0 tm0 (ax25-tools; names vary by distro)
+```
+
+## Dual protocol (USB CDC)
+
+| Traffic | Rule |
+|---------|------|
+| Packet | Bytes starting with / continuing **`0xC0` KISS** → HDLC TX / RX encode |
+| Service | Line-oriented ASCII → `PTT`, `STATUS`, `INI`, … |
+| Custom terminals | Prefer **KISS-only** for packet; service commands optional |
+
+## Host path (summary)
+
+| Traffic | Format |
+|---------|--------|
+| Packet | Binary **KISS** frames (`0xC0` … `0xC0`) — DATA cmd `0x00` |
+| Service | Line ASCII: `PTT`, `STATUS`, `ID`, `QUIET`/`VERBOSE`, `INI` / `GET` / `SET` / `SAVE` |
+
+## Pins
+
+| GPIO / rail | Role |
+|-------------|------|
+| GP0 / GP1 | I2C LCD |
+| GP6 | Modem **TXD** — HDLC bitbang out |
+| GP7 | Modem **RXD** — HDLC bitbang in + Packet UI sense |
+| GP8 | Modem **CDT** → Signal/RX |
+| GP9 | **PTT** (auto on TX + manual CDC) |
+| GP10–12 | Optional LEDs |
+| — | LED_PWR on **5 V rail** |
+
+## Boot banner
+
+Fixed source text in `src/common/banner.c` / `include/version.h` (not INI / CDC). **Always** printed once after USB CDC ready. Edit source only — no `banner=` INI key, no `BANNER ON|OFF`. `quiet=1` never suppresses the boot banner (only extra STATUS/PROBE/CONFIG ASCII).
+
+## USB CDC ASCII
+
+| Command | Effect |
+|---------|--------|
+| `HELP` | Command list |
+| `STATUS` | state, PTT, signal, packet, bitrate, raw CDT/RXD |
+| `ID` | `device_id` / `callsign` / bitrate / quiet (solicited) |
+| `PTT ON` / `PTT OFF` | Key GP9 |
+| `QUIET ON` / `OFF` | Suppress extra unsolicited CDC ASCII (default ON via INI `quiet=1`; never the boot banner) |
+| `VERBOSE ON` / `OFF` | Alias: `QUIET OFF` / `QUIET ON` |
+| `INI` / `GET` / `SET` / `SAVE` / `RELOAD` / `DEFAULTS` | Config |
+
+**`SAVE`:** writes a config **blob** into the **last 4 KiB** of Pico flash — **not** a filesystem.
+
+## INI (`[terminal]`)
+
+| Key | Default | Role |
+|-----|---------|------|
+| `callsign` | empty | Operator call / station string for `ID` |
+| `device_id` | `c1224` | Short device mark (`c1224` or `tmodem`) |
+| `quiet` | `1` | Suppress extra unsolicited CDC ASCII (not boot banner) |
+
+Display text keys (`starting`, `ready`, …) and sense keys unchanged. See [`share/c1224.ini.example`](../../share/c1224.ini.example).
+
+## Rate
+
+Stuff **Y1 4.4336** (1200) or **Y2 6.5536** (2400). Firmware does not switch baud at runtime. Rebuild with `-DC1224_BITRATE=2400` for Y2 boards.
diff --git a/firmware/c1224/config/default.ini b/firmware/c1224/config/default.ini
new file mode 100644
index 0000000..ff07c9e
--- /dev/null
+++ b/firmware/c1224/config/default.ini
@@ -0,0 +1,25 @@
+# T-Modem-c1224 — default operator texts (embedded in FW; edit via CDC SET/SAVE)
+# Physical rate = stuffed crystal (Y1 4.4336 → 1200 · Y2 6.5536 → 2400). Not a FW key.
+# Boot banner is fixed in firmware source (always printed). quiet=1 suppresses extra ASCII.
+
+[texts]
+starting=c1224 is starting
+ready=c1224 is ready
+fault=c1224 is fault
+sending=c1224 is sending
+signal=c1224 signal
+packet=c1224 packet
+power=c1224 power
+idle=c1224 idle
+
+[terminal]
+callsign=
+device_id=c1224
+quiet=1
+
+[sense]
+# CDT active level: 0 = active-low (default, pull-up idle), 1 = active-high
+cdt_active_high=0
+packet_hold_ms=500
+packet_edge_min=4
+packet_window_ms=50
diff --git a/firmware/c1224/include/afsk_bitbang.h b/firmware/c1224/include/afsk_bitbang.h
new file mode 100644
index 0000000..886b912
--- /dev/null
+++ b/firmware/c1224/include/afsk_bitbang.h
@@ -0,0 +1,30 @@
+/**
+ * Bitbang / timed NRZI on TCM3105 TXD (GP6) and RXD (GP7).
+ * Rate matches stuffed crystal — compile C1224_BITRATE=1200|2400.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef C1224_AFSK_BITBANG_H
+#define C1224_AFSK_BITBANG_H
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+#ifndef C1224_BITRATE
+#define C1224_BITRATE 1200
+#endif
+
+void afsk_bitbang_init(void);
+
+/** Transmit HDLC info payload (AX.25 frame without flags/FCS). Auto-PTT. */
+bool afsk_tx_frame(const uint8_t *info, size_t info_len);
+
+/** Poll RX sampler; call often from main loop. May emit via callback. */
+typedef void (*afsk_rx_frame_fn)(const uint8_t *info, size_t len);
+void afsk_rx_set_callback(afsk_rx_frame_fn fn);
+void afsk_rx_poll(void);
+
+bool afsk_tx_busy(void);
+unsigned afsk_bitrate(void);
+
+#endif /* C1224_AFSK_BITBANG_H */
diff --git a/firmware/c1224/include/banner.h b/firmware/c1224/include/banner.h
new file mode 100644
index 0000000..1592eb7
--- /dev/null
+++ b/firmware/c1224/include/banner.h
@@ -0,0 +1,12 @@
+/**
+ * Fixed CDC boot banner — source text only (not INI / CDC keys).
+ * Always printed once after USB ready; quiet does not suppress it.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef C1224_BANNER_H
+#define C1224_BANNER_H
+
+/** Print fixed boot banner (call once after CDC ready). */
+void banner_print(void);
+
+#endif /* C1224_BANNER_H */
diff --git a/firmware/c1224/include/baud_config.h b/firmware/c1224/include/baud_config.h
new file mode 100644
index 0000000..268ca15
--- /dev/null
+++ b/firmware/c1224/include/baud_config.h
@@ -0,0 +1,14 @@
+#ifndef C1224_BAUD_CONFIG_H
+#define C1224_BAUD_CONFIG_H
+
+/**
+ * Informational only — physical rate = stuffed crystal (Y1/Y2).
+ * One FW image; no baud switch; no dual UF2.
+ */
+typedef struct {
+ const char *label; /* "crystal" — rate is board stuffing */
+} baud_crystal_config_t;
+
+const baud_crystal_config_t *baud_config_get(void);
+
+#endif
diff --git a/firmware/c1224/include/board_pins.h b/firmware/c1224/include/board_pins.h
new file mode 100644
index 0000000..1422335
--- /dev/null
+++ b/firmware/c1224/include/board_pins.h
@@ -0,0 +1,52 @@
+/**
+ * T-Modem-c1224 pin map (service Pico — not softmodem).
+ * Frozen copper intent (v0.25 schematic) reused as planning default for 180×180.
+ * GP14/GP15 = unused mux placeholders for v1.0 (single-XTAL; stuff matches image).
+ */
+#ifndef C1224_BOARD_PINS_H
+#define C1224_BOARD_PINS_H
+
+#include <stdint.h>
+#include <stdbool.h>
+
+/* I2C LCD (shared-kit class; level-shifted on board) */
+#define PIN_I2C_SDA 0 /* GP0 */
+#define PIN_I2C_SCL 1 /* GP1 */
+#define I2C_LCD_BAUDRATE 100000
+#define I2C_LCD_ADDR_DEFAULT 0x27 /* common PCF8574 backpack; probe may fail → log-only */
+
+/* Modem chip digital (TCM3105-class) — Pico does NOT generate AFSK */
+#define PIN_MODEM_TXD 6 /* GP6 → chip TXD */
+#define PIN_MODEM_RXD 7 /* GP7 ← chip RXD → Packet UI sense (required · status only) */
+#define PIN_MODEM_CDT 8 /* GP8 ← chip CDT → Signal/RX (required) */
+
+/* PTT keying: Pico GPIO → OC → radio PTT (USB CDC service). Not the TX LED. */
+#define PIN_PTT_DRV 9 /* GP9 · required for USB PTT ON */
+
+/*
+ * Status LEDs (c1224 copper — active-high source via series R; LED cathode = GND).
+ * LED_PWR is NOT a GPIO: +5V_OR → R_LED1 → LED_PWR → GND (rail Power indicator).
+ *
+ * Settled UI (2026-07-26):
+ * - Dual sense REQUIRED: CDT→GP8 Signal/RX · RXD→GP7 Packet (status only, not data bridge).
+ * - CDT = carrier/tone · RXD activity ≠ full AX.25 decode.
+ * - LED_PTT (GP10) = OPTIONAL/SPARE — not required product TX indicator.
+ * - Display TX optional; PTT keying GPIO (GP9) stays separate from LED_PTT.
+ * - LED_TXRX = spare/activity.
+ */
+#define PIN_LED_PTT 10 /* GP10 · LED_PTT_N · optional/spare (not required TX LED) */
+#define PIN_LED_DCD 11 /* GP11 · LED_DCD_N · optional RX mirror of CDT */
+#define PIN_LED_TXRX 12 /* GP12 · LED_TXRX_N · spare / optional activity */
+
+/*
+ * Crystal mux placeholders — UNUSED for v1.0.
+ * Product: one crystal stuffed matching flash (Y1 4.4336 / Y2 6.5536).
+ * Dual/mux path abandoned; crystal_steer is a no-op.
+ * Optional keep defines if mux ever returns later.
+ */
+#define PIN_XTAL_SEL_A 14 /* GP14 — unused v1.0 */
+#define PIN_XTAL_SEL_B 15 /* GP15 — unused v1.0 */
+
+void board_pins_init(void);
+
+#endif /* C1224_BOARD_PINS_H */
diff --git a/firmware/c1224/include/config.h b/firmware/c1224/include/config.h
new file mode 100644
index 0000000..07b1093
--- /dev/null
+++ b/firmware/c1224/include/config.h
@@ -0,0 +1,58 @@
+/**
+ * Operator INI texts + sense tuning — defaults in FW, overlay from flash.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef C1224_CONFIG_H
+#define C1224_CONFIG_H
+
+#include <stdbool.h>
+#include <stdint.h>
+#include <stddef.h>
+
+#define C1224_TEXT_MAX 48
+
+typedef struct {
+ char starting[C1224_TEXT_MAX];
+ char ready[C1224_TEXT_MAX];
+ char fault[C1224_TEXT_MAX];
+ char sending[C1224_TEXT_MAX];
+ char signal[C1224_TEXT_MAX];
+ char packet[C1224_TEXT_MAX];
+ char power[C1224_TEXT_MAX];
+ char idle[C1224_TEXT_MAX];
+ /* Terminal-facing identity (CDC ID) */
+ char callsign[C1224_TEXT_MAX];
+ char device_id[C1224_TEXT_MAX];
+ /* Extra CDC ASCII — quiet=1 default; never suppresses fixed boot banner */
+ bool quiet; /* true = no unsolicited STATUS/PROBE/CONFIG ASCII */
+ /* Sense tuning */
+ bool cdt_active_high; /* false = active-low (default pull-up idle) */
+ uint16_t packet_hold_ms;
+ uint16_t packet_edge_min;
+ uint16_t packet_window_ms;
+} c1224_config_t;
+
+void config_init(void);
+const c1224_config_t *config_get(void);
+c1224_config_t *config_mutable(void);
+/** True when extra unsolicited CDC ASCII (STATUS/PROBE/CONFIG) is allowed. */
+bool config_unsolicited_ok(void);
+
+/** Apply defaults (RAM only). */
+void config_load_defaults(void);
+/** Load overlay from flash INI region if valid. */
+bool config_load_flash(void);
+/** Persist current config as INI text to flash. */
+bool config_save_flash(void);
+/** Erase flash overlay (next boot = defaults only until SAVE). */
+bool config_erase_flash(void);
+
+/** SET KEY=value (case-insensitive key). Returns false if unknown key. */
+bool config_set_kv(const char *key, const char *value);
+/** GET KEY → copy value into buf. */
+bool config_get_kv(const char *key, char *buf, size_t buflen);
+
+/** Dump INI-like text to USB (printf). */
+void config_dump_ini(void);
+
+#endif
diff --git a/firmware/c1224/include/crystal_steer.h b/firmware/c1224/include/crystal_steer.h
new file mode 100644
index 0000000..9aa55f9
--- /dev/null
+++ b/firmware/c1224/include/crystal_steer.h
@@ -0,0 +1,10 @@
+#ifndef C1224_CRYSTAL_STEER_H
+#define C1224_CRYSTAL_STEER_H
+
+#include <stdbool.h>
+
+/** v1.0 no-op — rate = stuffed crystal; no XTAL mux. */
+void crystal_steer_apply(void);
+bool crystal_steer_applied(void);
+
+#endif
diff --git a/firmware/c1224/include/display.h b/firmware/c1224/include/display.h
new file mode 100644
index 0000000..1cd78f1
--- /dev/null
+++ b/firmware/c1224/include/display.h
@@ -0,0 +1,17 @@
+#ifndef C1224_DISPLAY_H
+#define C1224_DISPLAY_H
+
+#include <stdbool.h>
+
+/**
+ * I2C HD44780 via PCF8574 backpack (0x27/0x3F). USB always logs.
+ * Shows Power/ready, Signal, Packet from INI texts.
+ */
+void display_init(void);
+bool display_present(void);
+/** Refresh LCD from current status + sense (uses INI texts). */
+void display_update(void);
+/** Legacy single-line push (also used before full UI ready). */
+void display_show(const char *line);
+
+#endif
diff --git a/firmware/c1224/include/hdlc.h b/firmware/c1224/include/hdlc.h
new file mode 100644
index 0000000..1d64278
--- /dev/null
+++ b/firmware/c1224/include/hdlc.h
@@ -0,0 +1,49 @@
+/**
+ * HDLC frame helpers — flags, bit-stuff, FCS-CCITT (AX.25).
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef C1224_HDLC_H
+#define C1224_HDLC_H
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+#define HDLC_FLAG 0x7Eu
+#define HDLC_FCS_INIT 0xFFFFu
+#define HDLC_FCS_GOOD 0xF0B8u
+#define HDLC_MAX_INFO 512
+
+uint16_t hdlc_fcs_update(uint16_t fcs, uint8_t byte);
+uint16_t hdlc_fcs_final(uint16_t fcs);
+
+/**
+ * Build on-wire bit stream (NRZI already applied by caller after this):
+ * FLAG + stuffed(info + FCS_lo + FCS_hi) + FLAG(+optional trailing flags).
+ * Returns bit count written to bits[] (MSB-first within each stuffed byte stream
+ * is not used — output is a raw bit array, LSB-first per AX.25 bit order).
+ *
+ * bits_cap is capacity in bits. Returns 0 on overflow / bad args.
+ */
+size_t hdlc_encode_bits(const uint8_t *info, size_t info_len,
+ uint8_t *bits, size_t bits_cap,
+ unsigned trailing_flags);
+
+/**
+ * Feed one NRZ bit (after NRZI decode). Returns true when a complete good
+ * frame is available in out[] (info only, FCS stripped).
+ */
+typedef struct {
+ uint8_t buf[HDLC_MAX_INFO + 2];
+ size_t len;
+ uint8_t ones;
+ bool in_frame;
+ bool stuffing;
+ uint8_t byte_acc;
+ uint8_t bit_count;
+} hdlc_rx_t;
+
+void hdlc_rx_init(hdlc_rx_t *rx);
+bool hdlc_rx_bit(hdlc_rx_t *rx, bool bit, uint8_t *out, size_t *out_len);
+
+#endif /* C1224_HDLC_H */
diff --git a/firmware/c1224/include/kiss.h b/firmware/c1224/include/kiss.h
new file mode 100644
index 0000000..0312bb4
--- /dev/null
+++ b/firmware/c1224/include/kiss.h
@@ -0,0 +1,34 @@
+/**
+ * KISS frame encode/decode (FEND 0xC0).
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef C1224_KISS_H
+#define C1224_KISS_H
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+#define KISS_FEND 0xC0u
+#define KISS_FESC 0xDBu
+#define KISS_TFEND 0xDCu
+#define KISS_TFESC 0xDDu
+#define KISS_CMD_DATA 0x00u
+#define KISS_MAX_PAYLOAD 512
+
+typedef struct {
+ uint8_t buf[KISS_MAX_PAYLOAD + 4];
+ size_t len;
+ bool in_frame;
+ bool esc;
+} kiss_rx_t;
+
+void kiss_rx_init(kiss_rx_t *kx);
+/** Feed one byte. Returns true when a DATA payload is ready in out. */
+bool kiss_rx_byte(kiss_rx_t *kx, uint8_t b, uint8_t *out, size_t *out_len);
+
+/** Write KISS DATA frame to dst; returns bytes written (0 on overflow). */
+size_t kiss_encode_data(const uint8_t *payload, size_t len,
+ uint8_t *dst, size_t dst_cap);
+
+#endif /* C1224_KISS_H */
diff --git a/firmware/c1224/include/modem_probe.h b/firmware/c1224/include/modem_probe.h
new file mode 100644
index 0000000..402e485
--- /dev/null
+++ b/firmware/c1224/include/modem_probe.h
@@ -0,0 +1,15 @@
+#ifndef C1224_MODEM_PROBE_H
+#define C1224_MODEM_PROBE_H
+
+#include <stdbool.h>
+
+typedef struct {
+ bool crystal_driven;
+ bool cdt_readable;
+ bool rxd_readable;
+ bool ready;
+} modem_probe_result_t;
+
+modem_probe_result_t modem_probe_run(void);
+
+#endif
diff --git a/firmware/c1224/include/ptt.h b/firmware/c1224/include/ptt.h
new file mode 100644
index 0000000..ca5e7d8
--- /dev/null
+++ b/firmware/c1224/include/ptt.h
@@ -0,0 +1,10 @@
+#ifndef C1224_PTT_H
+#define C1224_PTT_H
+
+#include <stdbool.h>
+
+void ptt_init(void);
+void ptt_set(bool on);
+bool ptt_get(void);
+
+#endif
diff --git a/firmware/c1224/include/sense.h b/firmware/c1224/include/sense.h
new file mode 100644
index 0000000..25009e8
--- /dev/null
+++ b/firmware/c1224/include/sense.h
@@ -0,0 +1,19 @@
+/**
+ * Dual sense: CDT→Signal/RX, RXD→Packet (HDLC 0x7E / edge activity).
+ * Status only — not a Host↔Chip data bridge.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef C1224_SENSE_H
+#define C1224_SENSE_H
+
+#include <stdbool.h>
+
+void sense_init(void);
+/** Poll GPIO / heuristics; call from main loop (~10 ms). */
+void sense_poll(void);
+
+bool sense_signal_active(void); /* CDT carrier/tone */
+bool sense_packet_active(void); /* RXD activity or flag seen (held) */
+bool sense_packet_flag(void); /* last window saw 0x7E */
+
+#endif
diff --git a/firmware/c1224/include/status.h b/firmware/c1224/include/status.h
new file mode 100644
index 0000000..fefe6e8
--- /dev/null
+++ b/firmware/c1224/include/status.h
@@ -0,0 +1,23 @@
+#ifndef C1224_STATUS_H
+#define C1224_STATUS_H
+
+#include <stdbool.h>
+
+typedef enum {
+ C1224_STATE_STARTING = 0,
+ C1224_STATE_READY,
+ C1224_STATE_FAULT,
+ C1224_STATE_SENDING,
+ C1224_STATE_SIGNAL, /* CDT active (Signal/RX) */
+ C1224_STATE_PACKET /* RXD packet sense (may coexist with signal) */
+} c1224_state_t;
+
+/** Operator-facing text for state (from INI). */
+const char *c1224_state_string(c1224_state_t st);
+void status_set(c1224_state_t st);
+c1224_state_t status_get(void);
+/** Recompute composite state from PTT + sense; publish if changed. */
+void status_tick(void);
+void status_publish(void);
+
+#endif
diff --git a/firmware/c1224/include/usb_cmd.h b/firmware/c1224/include/usb_cmd.h
new file mode 100644
index 0000000..e0fb513
--- /dev/null
+++ b/firmware/c1224/include/usb_cmd.h
@@ -0,0 +1,11 @@
+#ifndef C1224_USB_CMD_H
+#define C1224_USB_CMD_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+void usb_cmd_init(void);
+void usb_cmd_poll(void);
+void usb_cmd_on_rx_frame(const uint8_t *info, size_t len);
+
+#endif
diff --git a/firmware/c1224/include/version.h b/firmware/c1224/include/version.h
new file mode 100644
index 0000000..a2c81ad
--- /dev/null
+++ b/firmware/c1224/include/version.h
@@ -0,0 +1,11 @@
+/**
+ * Firmware version SSoT — bump here; banner and docs follow.
+ * Matches product tag style without leading 'v' (tag: v1.0-prototype).
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef C1224_VERSION_H
+#define C1224_VERSION_H
+
+#define C1224_FW_VERSION "1.0-prototype"
+
+#endif /* C1224_VERSION_H */
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