summaryrefslogtreecommitdiff
path: root/firmware/s1224
diff options
context:
space:
mode:
Diffstat (limited to 'firmware/s1224')
-rw-r--r--firmware/s1224/CMakeLists.txt67
-rw-r--r--firmware/s1224/README.md95
-rw-r--r--firmware/s1224/config/default.ini22
-rw-r--r--firmware/s1224/include/afsk_profile.h35
-rw-r--r--firmware/s1224/include/afsk_soft.h32
-rw-r--r--firmware/s1224/include/banner.h12
-rw-r--r--firmware/s1224/include/baud_config.h10
-rw-r--r--firmware/s1224/include/board_pins.h39
-rw-r--r--firmware/s1224/include/config.h58
-rw-r--r--firmware/s1224/include/crystal_steer.h10
-rw-r--r--firmware/s1224/include/display.h17
-rw-r--r--firmware/s1224/include/hdlc.h49
-rw-r--r--firmware/s1224/include/kiss.h34
-rw-r--r--firmware/s1224/include/modem_probe.h14
-rw-r--r--firmware/s1224/include/ptt.h10
-rw-r--r--firmware/s1224/include/sense.h19
-rw-r--r--firmware/s1224/include/status.h23
-rw-r--r--firmware/s1224/include/usb_cmd.h11
-rw-r--r--firmware/s1224/include/version.h9
-rw-r--r--firmware/s1224/src/common/afsk_soft.c224
-rw-r--r--firmware/s1224/src/common/banner.c82
-rw-r--r--firmware/s1224/src/common/baud_config.c19
-rw-r--r--firmware/s1224/src/common/board_pins.c47
-rw-r--r--firmware/s1224/src/common/config.c422
-rw-r--r--firmware/s1224/src/common/crystal_steer.c17
-rw-r--r--firmware/s1224/src/common/display.c187
-rw-r--r--firmware/s1224/src/common/hdlc.c192
-rw-r--r--firmware/s1224/src/common/kiss.c98
-rw-r--r--firmware/s1224/src/common/main.c61
-rw-r--r--firmware/s1224/src/common/modem_probe.c22
-rw-r--r--firmware/s1224/src/common/ptt.c31
-rw-r--r--firmware/s1224/src/common/sense.c72
-rw-r--r--firmware/s1224/src/common/status.c78
-rw-r--r--firmware/s1224/src/common/usb_cmd.c267
34 files changed, 2385 insertions, 0 deletions
diff --git a/firmware/s1224/CMakeLists.txt b/firmware/s1224/CMakeLists.txt
new file mode 100644
index 0000000..a255afb
--- /dev/null
+++ b/firmware/s1224/CMakeLists.txt
@@ -0,0 +1,67 @@
+# T-Modem-s1224 Pico firmware — softmodem · three UF2 images (build separately)
+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(s1224_firmware C CXX ASM)
+set(CMAKE_C_STANDARD 11)
+set(CMAKE_CXX_STANDARD 17)
+
+pico_sdk_init()
+
+if(NOT DEFINED S1224_BITRATE)
+ set(S1224_BITRATE 1200)
+endif()
+
+if(S1224_BITRATE EQUAL 300)
+ set(S1224_TARGET s1224-300)
+elseif(S1224_BITRATE EQUAL 1200)
+ set(S1224_TARGET s1224-1200)
+elseif(S1224_BITRATE EQUAL 2400)
+ set(S1224_TARGET s1224-2400)
+else()
+ message(FATAL_ERROR "S1224_BITRATE must be 300, 1200, or 2400")
+endif()
+
+add_executable(${S1224_TARGET}
+ 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_soft.c
+ src/common/banner.c
+)
+
+target_include_directories(${S1224_TARGET} PRIVATE include)
+target_compile_definitions(${S1224_TARGET} PRIVATE S1224_BITRATE=${S1224_BITRATE})
+target_link_libraries(${S1224_TARGET}
+ pico_stdlib
+ hardware_gpio
+ hardware_i2c
+ hardware_flash
+ hardware_sync
+ hardware_pwm
+ hardware_adc
+ m
+)
+
+pico_enable_stdio_usb(${S1224_TARGET} 1)
+pico_enable_stdio_uart(${S1224_TARGET} 0)
+pico_add_extra_outputs(${S1224_TARGET})
+pico_set_binary_type(${S1224_TARGET} default)
diff --git a/firmware/s1224/README.md b/firmware/s1224/README.md
new file mode 100644
index 0000000..976947c
--- /dev/null
+++ b/firmware/s1224/README.md
@@ -0,0 +1,95 @@
+# T-Modem-s1224 — Pico USB KISS half-TNC + softmodem
+
+**AS-IS** · GPLv3 (repo `LICENSE`) · no support, no warranty · **v1.0-prototype**.
+
+**Three** UF2 images for Raspberry Pi Pico (RP2040). Softmodem does air AFSK; Pico is **USB KISS half-TNC** (HDLC) plus display / PTT / sense. **No** TCM3105 chip path.
+
+| Owns | Does **not** |
+|------|----------------|
+| HDLC TX/RX (flags, bit-stuff, FCS-CCITT) | Chip bitbang / TCM3105 TXD-RXD |
+| Soft AFSK: PWM TX (GP6) · Goertzel RX (GP26 ADC) | Runtime baud switch |
+| USB CDC: KISS `0xC0` + ASCII service | XTAL-rate crystal steering |
+| Auto PTT GP9 during KISS TX · `PTT ON`/`OFF` | Modem IC / socket |
+| Display / INI texts · carrier sense · fixed boot banner | INI/CDC boot-banner toggle |
+
+## Tone profiles
+
+| `-DS1224_BITRATE=` | UF2 | Mark / Space Hz |
+|--------------------|-----|-----------------|
+| `300` | `s1224-300.uf2` | 1600 / 1800 |
+| `1200` (default) | `s1224-1200.uf2` | 1200 / 2200 (Bell 202) |
+| `2400` | `s1224-2400.uf2` | ~1775 / 3250 (c1224 Y2-class) |
+
+## Build
+
+Requires [Pico SDK](https://github.com/raspberrypi/pico-sdk) and `arm-none-eabi-gcc`.
+
+```bash
+export PICO_SDK_PATH=/path/to/pico-sdk # e.g. T-Modem-c1224/.deps/pico-sdk
+cd firmware/s1224
+mkdir -p build && cd build
+cmake .. -DS1224_BITRATE=1200 # or 300 / 2400
+make -j$(nproc)
+# → s1224-1200.uf2 s1224-1200.elf
+```
+
+Rebuild separately for each bitrate (clean build dir or out-of-tree dirs recommended).
+
+### Flash
+
+1. Hold **BOOTSEL**, plug USB (board power = **12 V** barrel).
+2. Copy `s1224-*.uf2` to RPI-RP2 (or `picotool load -f …`).
+3. Host opens `/dev/ttyACM*` (USB CDC).
+
+## Any KISS terminal (standalone — no MAX25)
+
+**MAX25-Stack is not required.** **All terminals should work insofar as applicable** — flash UF2 → plug USB → open `/dev/ttyACM*` → KISS `0xC0` or ASCII `HELP`.
+
+| Step | Detail |
+|------|--------|
+| Device | `/dev/ttyACM*` |
+| Framing | Raw **8N1** · binary **KISS** (`0xC0` … `0xC0`) · DATA cmd `0x00` |
+| Boot | Fixed source banner **always** once · `quiet=1` damps extra STATUS/CONFIG ASCII only |
+
+## Pins
+
+| GPIO / rail | Role |
+|-------------|------|
+| GP0 / GP1 | I2C LCD |
+| GP6 | **AF_TX** — PWM AFSK out (bypass DNI modem footprint) |
+| GP26 | **AF_RX** — ADC0 soft demod in |
+| GP7 / GP8 | Legacy chip pads — leave open on softmodem boards |
+| 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`. **Always** printed once after USB CDC ready.
+
+## USB CDC ASCII
+
+| Command | Effect |
+|---------|--------|
+| `HELP` | Command list |
+| `STATUS` | state, PTT, signal, packet, carrier, bitrate |
+| `ID` | `device_id` / `callsign` / bitrate / quiet |
+| `PTT ON` / `PTT OFF` | Key GP9 |
+| `QUIET ON` / `OFF` | Suppress extra unsolicited CDC ASCII |
+| `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` | `s1224` | Short device mark |
+| `quiet` | `1` | Suppress extra unsolicited CDC ASCII (not boot banner) |
+
+See [`share/s1224.ini.example`](../../share/s1224.ini.example).
+
+## Rate
+
+Firmware does not switch baud at runtime. Flash the UF2 that matches the intended tone profile.
diff --git a/firmware/s1224/config/default.ini b/firmware/s1224/config/default.ini
new file mode 100644
index 0000000..6f24e7e
--- /dev/null
+++ b/firmware/s1224/config/default.ini
@@ -0,0 +1,22 @@
+# T-Modem-s1224 built-in defaults (flash override via CDC SAVE)
+
+[display]
+starting=s1224 is starting
+ready=s1224 is ready
+fault=s1224 is fault
+sending=s1224 is sending
+signal=s1224 signal
+packet=s1224 packet
+power=s1224 power
+idle=s1224 idle
+
+[terminal]
+callsign=
+device_id=s1224
+quiet=1
+
+[sense]
+cdt_active_high=0
+packet_hold_ms=500
+packet_edge_min=4
+packet_window_ms=50
diff --git a/firmware/s1224/include/afsk_profile.h b/firmware/s1224/include/afsk_profile.h
new file mode 100644
index 0000000..da0fc29
--- /dev/null
+++ b/firmware/s1224/include/afsk_profile.h
@@ -0,0 +1,35 @@
+/**
+ * Mark/space tone pairs per build (Hz) — see product README.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef S1224_AFSK_PROFILE_H
+#define S1224_AFSK_PROFILE_H
+
+#include <stdint.h>
+
+#ifndef S1224_BITRATE
+#define S1224_BITRATE 1200
+#endif
+
+typedef struct {
+ uint32_t baud;
+ uint32_t mark_hz;
+ uint32_t space_hz;
+ const char *profile_name;
+} afsk_profile_t;
+
+static inline afsk_profile_t afsk_profile_get(void)
+{
+#if S1224_BITRATE == 300
+ return (afsk_profile_t){300, 1600, 1800, "HF-300 (1600/1800)"};
+#elif S1224_BITRATE == 1200
+ return (afsk_profile_t){1200, 1200, 2200, "Bell202-1200"};
+#elif S1224_BITRATE == 2400
+ /* c1224 Y2 / 6.5536 MHz field overclock class — NOT Dire Wolf 2400 */
+ return (afsk_profile_t){2400, 1775, 3250, "Y2-class-2400"};
+#else
+#error "S1224_BITRATE must be 300, 1200, or 2400"
+#endif
+}
+
+#endif /* S1224_AFSK_PROFILE_H */
diff --git a/firmware/s1224/include/afsk_soft.h b/firmware/s1224/include/afsk_soft.h
new file mode 100644
index 0000000..5a23141
--- /dev/null
+++ b/firmware/s1224/include/afsk_soft.h
@@ -0,0 +1,32 @@
+/**
+ * Softmodem AFSK — PWM/DDS TX + Goertzel RX (ADC when wired).
+ * Profile tones selected at compile time via S1224_BITRATE=300|1200|2400.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef S1224_AFSK_SOFT_H
+#define S1224_AFSK_SOFT_H
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+#ifndef S1224_BITRATE
+#define S1224_BITRATE 1200
+#endif
+
+void afsk_soft_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);
+
+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);
+
+/** Carrier/energy sense for UI (softmodem — not chip CDT). */
+bool afsk_rx_carrier_active(void);
+
+#endif /* S1224_AFSK_SOFT_H */
diff --git a/firmware/s1224/include/banner.h b/firmware/s1224/include/banner.h
new file mode 100644
index 0000000..d9bc1a2
--- /dev/null
+++ b/firmware/s1224/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 S1224_BANNER_H
+#define S1224_BANNER_H
+
+/** Print fixed boot banner (call once after CDC ready). */
+void banner_print(void);
+
+#endif /* S1224_BANNER_H */
diff --git a/firmware/s1224/include/baud_config.h b/firmware/s1224/include/baud_config.h
new file mode 100644
index 0000000..28e07ee
--- /dev/null
+++ b/firmware/s1224/include/baud_config.h
@@ -0,0 +1,10 @@
+#ifndef S1224_BAUD_CONFIG_H
+#define S1224_BAUD_CONFIG_H
+
+typedef struct {
+ const char *label;
+} baud_profile_config_t;
+
+const baud_profile_config_t *baud_config_get(void);
+
+#endif
diff --git a/firmware/s1224/include/board_pins.h b/firmware/s1224/include/board_pins.h
new file mode 100644
index 0000000..0fb1a21
--- /dev/null
+++ b/firmware/s1224/include/board_pins.h
@@ -0,0 +1,39 @@
+/**
+ * T-Modem-s1224 pin map — softmodem service Pico (same PCB base as c1224).
+ * No TCM3105: PWM AF out bypasses chip socket (prototype jumper to AF path).
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef S1224_BOARD_PINS_H
+#define S1224_BOARD_PINS_H
+
+#include <stdint.h>
+#include <stdbool.h>
+
+#define S1224_HAS_CHIP_MODEM 0
+
+/* I2C LCD (shared-kit class; level-shifted on board) */
+#define PIN_I2C_SDA 0
+#define PIN_I2C_SCL 1
+#define I2C_LCD_BAUDRATE 100000
+#define I2C_LCD_ADDR_DEFAULT 0x27
+
+/* GP6 · PCB net AF_TX · PWM AFSK out */
+#define PIN_AF_TX 6
+
+/* GP26 · PCB net AF_RX when routed · ADC channel 0 */
+#define PIN_AF_RX_ADC 26
+
+/* GP7/GP8 · NC on s1224 PCB · optional FW sense if wired */
+#define PIN_GP7_SENSE 7
+#define PIN_MODEM_RXD PIN_GP7_SENSE /* alias · was chip RXD name */
+#define PIN_MODEM_CDT 8 /* unused · NC on board */
+
+#define PIN_PTT_DRV 9 /* GP9 · PTT keying */
+
+#define PIN_LED_PTT 10
+#define PIN_LED_DCD 11
+#define PIN_LED_TXRX 12
+
+void board_pins_init(void);
+
+#endif /* S1224_BOARD_PINS_H */
diff --git a/firmware/s1224/include/config.h b/firmware/s1224/include/config.h
new file mode 100644
index 0000000..9792aed
--- /dev/null
+++ b/firmware/s1224/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 S1224_CONFIG_H
+#define S1224_CONFIG_H
+
+#include <stdbool.h>
+#include <stdint.h>
+#include <stddef.h>
+
+#define S1224_TEXT_MAX 48
+
+typedef struct {
+ char starting[S1224_TEXT_MAX];
+ char ready[S1224_TEXT_MAX];
+ char fault[S1224_TEXT_MAX];
+ char sending[S1224_TEXT_MAX];
+ char signal[S1224_TEXT_MAX];
+ char packet[S1224_TEXT_MAX];
+ char power[S1224_TEXT_MAX];
+ char idle[S1224_TEXT_MAX];
+ /* Terminal-facing identity (CDC ID) */
+ char callsign[S1224_TEXT_MAX];
+ char device_id[S1224_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;
+} s1224_config_t;
+
+void config_init(void);
+const s1224_config_t *config_get(void);
+s1224_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/s1224/include/crystal_steer.h b/firmware/s1224/include/crystal_steer.h
new file mode 100644
index 0000000..43f0dd7
--- /dev/null
+++ b/firmware/s1224/include/crystal_steer.h
@@ -0,0 +1,10 @@
+#ifndef S1224_CRYSTAL_STEER_H
+#define S1224_CRYSTAL_STEER_H
+
+#include <stdbool.h>
+
+/** Softmodem — no-op (rate = compile-time S1224_BITRATE). */
+void crystal_steer_apply(void);
+bool crystal_steer_applied(void);
+
+#endif
diff --git a/firmware/s1224/include/display.h b/firmware/s1224/include/display.h
new file mode 100644
index 0000000..67b32ce
--- /dev/null
+++ b/firmware/s1224/include/display.h
@@ -0,0 +1,17 @@
+#ifndef S1224_DISPLAY_H
+#define S1224_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/s1224/include/hdlc.h b/firmware/s1224/include/hdlc.h
new file mode 100644
index 0000000..9b4cb22
--- /dev/null
+++ b/firmware/s1224/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 S1224_HDLC_H
+#define S1224_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 /* S1224_HDLC_H */
diff --git a/firmware/s1224/include/kiss.h b/firmware/s1224/include/kiss.h
new file mode 100644
index 0000000..66a890b
--- /dev/null
+++ b/firmware/s1224/include/kiss.h
@@ -0,0 +1,34 @@
+/**
+ * KISS frame encode/decode (FEND 0xC0).
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef S1224_KISS_H
+#define S1224_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 /* S1224_KISS_H */
diff --git a/firmware/s1224/include/modem_probe.h b/firmware/s1224/include/modem_probe.h
new file mode 100644
index 0000000..7e63904
--- /dev/null
+++ b/firmware/s1224/include/modem_probe.h
@@ -0,0 +1,14 @@
+#ifndef S1224_MODEM_PROBE_H
+#define S1224_MODEM_PROBE_H
+
+#include <stdbool.h>
+
+typedef struct {
+ bool softmodem_ok;
+ bool af_tx_pwm;
+ bool ready;
+} modem_probe_result_t;
+
+modem_probe_result_t modem_probe_run(void);
+
+#endif
diff --git a/firmware/s1224/include/ptt.h b/firmware/s1224/include/ptt.h
new file mode 100644
index 0000000..501c558
--- /dev/null
+++ b/firmware/s1224/include/ptt.h
@@ -0,0 +1,10 @@
+#ifndef S1224_PTT_H
+#define S1224_PTT_H
+
+#include <stdbool.h>
+
+void ptt_init(void);
+void ptt_set(bool on);
+bool ptt_get(void);
+
+#endif
diff --git a/firmware/s1224/include/sense.h b/firmware/s1224/include/sense.h
new file mode 100644
index 0000000..50933f3
--- /dev/null
+++ b/firmware/s1224/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 S1224_SENSE_H
+#define S1224_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/s1224/include/status.h b/firmware/s1224/include/status.h
new file mode 100644
index 0000000..3c62390
--- /dev/null
+++ b/firmware/s1224/include/status.h
@@ -0,0 +1,23 @@
+#ifndef S1224_STATUS_H
+#define S1224_STATUS_H
+
+#include <stdbool.h>
+
+typedef enum {
+ S1224_STATE_STARTING = 0,
+ S1224_STATE_READY,
+ S1224_STATE_FAULT,
+ S1224_STATE_SENDING,
+ S1224_STATE_SIGNAL, /* CDT active (Signal/RX) */
+ S1224_STATE_PACKET /* RXD packet sense (may coexist with signal) */
+} s1224_state_t;
+
+/** Operator-facing text for state (from INI). */
+const char *s1224_state_string(s1224_state_t st);
+void status_set(s1224_state_t st);
+s1224_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/s1224/include/usb_cmd.h b/firmware/s1224/include/usb_cmd.h
new file mode 100644
index 0000000..8f0ded6
--- /dev/null
+++ b/firmware/s1224/include/usb_cmd.h
@@ -0,0 +1,11 @@
+#ifndef S1224_USB_CMD_H
+#define S1224_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/s1224/include/version.h b/firmware/s1224/include/version.h
new file mode 100644
index 0000000..d1a288f
--- /dev/null
+++ b/firmware/s1224/include/version.h
@@ -0,0 +1,9 @@
+#ifndef S1224_VERSION_H
+#define S1224_VERSION_H
+/**
+ * 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
+ */
+#define S1224_FW_VERSION "1.0-prototype"
+#endif
diff --git a/firmware/s1224/src/common/afsk_soft.c b/firmware/s1224/src/common/afsk_soft.c
new file mode 100644
index 0000000..3e5d966
--- /dev/null
+++ b/firmware/s1224/src/common/afsk_soft.c
@@ -0,0 +1,224 @@
+/*
+ * Continuous-phase AFSK TX via PWM frequency (mark/space) + NRZI/HDLC.
+ * RX: per-bit Goertzel mark/space on ADC (GP26) when wired.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "afsk_soft.h"
+#include "afsk_profile.h"
+#include "hdlc.h"
+#include "board_pins.h"
+#include "ptt.h"
+#include "status.h"
+#include "hardware/gpio.h"
+#include "hardware/pwm.h"
+#include "hardware/clocks.h"
+#include "pico/stdlib.h"
+#include <string.h>
+#include <math.h>
+
+#define BIT_US (1000000u / (unsigned)S1224_BITRATE)
+#define TX_BITS_MAX ((HDLC_MAX_INFO + 4) * 10 + 64)
+#define PWM_CLKDIV 4.0f
+#define RX_SAMPLES_BIT 8
+#define RX_FS_HZ ((unsigned)S1224_BITRATE * RX_SAMPLES_BIT)
+#define CARRIER_THRESH 50.0f
+
+static uint8_t g_tx_bits[TX_BITS_MAX];
+static volatile bool g_tx_busy;
+static bool g_nrzi_mark;
+static hdlc_rx_t g_rx;
+static afsk_rx_frame_fn g_rx_cb;
+static uint8_t g_rx_out[HDLC_MAX_INFO];
+static uint g_pwm_slice;
+static uint g_pwm_chan;
+static afsk_profile_t g_prof;
+static bool g_carrier;
+
+#if PIN_AF_RX_ADC >= 0
+#include "hardware/adc.h"
+#define RX_GOERTZEL_ENABLE 1
+static float g_rx_samp[RX_SAMPLES_BIT];
+static unsigned g_rx_n;
+static absolute_time_t g_next_adc;
+static bool g_rx_nrzi_mark;
+static float g_coeff_mark;
+static float g_coeff_space;
+#else
+#define RX_GOERTZEL_ENABLE 0
+#endif
+
+static void pwm_tone_hz(uint32_t freq_hz)
+{
+ if (freq_hz < 100) {
+ pwm_set_enabled(g_pwm_slice, false);
+ return;
+ }
+ uint32_t sys = clock_get_hz(clk_sys);
+ uint32_t wrap = (uint32_t)((float)sys / (freq_hz * PWM_CLKDIV)) - 1u;
+ if (wrap < 10) {
+ wrap = 10;
+ }
+ pwm_set_clkdiv(g_pwm_slice, PWM_CLKDIV);
+ pwm_set_wrap(g_pwm_slice, wrap);
+ pwm_set_chan_level(g_pwm_slice, g_pwm_chan, wrap / 2u);
+ pwm_set_enabled(g_pwm_slice, true);
+}
+
+static void nrzi_play_bit(bool data_bit)
+{
+ if (!data_bit) {
+ g_nrzi_mark = !g_nrzi_mark;
+ }
+ pwm_tone_hz(g_nrzi_mark ? g_prof.mark_hz : g_prof.space_hz);
+ busy_wait_us_32(BIT_US);
+}
+
+#if RX_GOERTZEL_ENABLE
+static float goertzel_mag(const float *x, unsigned n, float coeff)
+{
+ float s0 = 0.0f, s1 = 0.0f, s2 = 0.0f;
+ for (unsigned i = 0; i < n; i++) {
+ s0 = x[i] + coeff * s1 - s2;
+ s2 = s1;
+ s1 = s0;
+ }
+ float power = s1 * s1 + s2 * s2 - coeff * s1 * s2;
+ return power < 0.0f ? 0.0f : power;
+}
+
+static float goertzel_coeff(uint32_t tone_hz)
+{
+ float w = 2.0f * (float)M_PI * ((float)tone_hz / (float)RX_FS_HZ);
+ return 2.0f * cosf(w);
+}
+#endif
+
+void afsk_soft_init(void)
+{
+ g_prof = afsk_profile_get();
+ gpio_set_function(PIN_AF_TX, GPIO_FUNC_PWM);
+ g_pwm_slice = pwm_gpio_to_slice_num(PIN_AF_TX);
+ g_pwm_chan = pwm_gpio_to_channel(PIN_AF_TX);
+ pwm_tone_hz(0);
+ g_tx_busy = false;
+ g_nrzi_mark = true;
+ hdlc_rx_init(&g_rx);
+ g_rx_cb = NULL;
+ g_carrier = false;
+
+#if RX_GOERTZEL_ENABLE
+ adc_init();
+ adc_gpio_init(PIN_AF_RX_ADC);
+ adc_select_input((uint)(PIN_AF_RX_ADC - 26));
+ g_rx_n = 0;
+ g_rx_nrzi_mark = true;
+ g_coeff_mark = goertzel_coeff(g_prof.mark_hz);
+ g_coeff_space = goertzel_coeff(g_prof.space_hz);
+ g_next_adc = get_absolute_time();
+#endif
+}
+
+unsigned afsk_bitrate(void)
+{
+ return (unsigned)S1224_BITRATE;
+}
+
+bool afsk_tx_busy(void)
+{
+ return g_tx_busy;
+}
+
+bool afsk_rx_carrier_active(void)
+{
+ return g_carrier;
+}
+
+void afsk_rx_set_callback(afsk_rx_frame_fn fn)
+{
+ g_rx_cb = fn;
+}
+
+bool afsk_tx_frame(const uint8_t *info, size_t info_len)
+{
+ if (g_tx_busy || !info || info_len == 0 || info_len > HDLC_MAX_INFO) {
+ return false;
+ }
+ size_t nbits = hdlc_encode_bits(info, info_len, g_tx_bits, TX_BITS_MAX, 2);
+ if (nbits == 0) {
+ return false;
+ }
+
+ g_tx_busy = true;
+ status_set(S1224_STATE_SENDING);
+ status_publish();
+ ptt_set(true);
+ sleep_ms(40);
+
+ g_nrzi_mark = true;
+ pwm_tone_hz(g_prof.mark_hz);
+
+ for (size_t i = 0; i < nbits; i++) {
+ nrzi_play_bit(g_tx_bits[i] != 0);
+ }
+
+ pwm_tone_hz(0);
+ sleep_ms(20);
+ ptt_set(false);
+ status_set(S1224_STATE_READY);
+ status_publish();
+ g_tx_busy = false;
+ return true;
+}
+
+#if RX_GOERTZEL_ENABLE
+static void rx_process_bit_window(void)
+{
+ float m = goertzel_mag(g_rx_samp, RX_SAMPLES_BIT, g_coeff_mark);
+ float s = goertzel_mag(g_rx_samp, RX_SAMPLES_BIT, g_coeff_space);
+ float peak = m > s ? m : s;
+ g_carrier = peak > CARRIER_THRESH;
+
+ bool tone_mark = (m >= s);
+ bool data_bit = (tone_mark == g_rx_nrzi_mark); /* NRZI: 1 = no transition */
+ if (tone_mark != g_rx_nrzi_mark) {
+ g_rx_nrzi_mark = tone_mark;
+ }
+
+ size_t out_len = 0;
+ if (hdlc_rx_bit(&g_rx, data_bit, g_rx_out, &out_len) && g_rx_cb && out_len > 0) {
+ g_rx_cb(g_rx_out, out_len);
+ }
+}
+
+static void rx_goertzel_sample(void)
+{
+ absolute_time_t now = get_absolute_time();
+ if (absolute_time_diff_us(g_next_adc, now) < 0) {
+ return;
+ }
+ g_next_adc = delayed_by_us(now, 1000000u / RX_FS_HZ);
+
+ uint16_t raw = adc_read();
+ /* Center around mid-scale (12-bit ADC). */
+ g_rx_samp[g_rx_n++] = ((float)raw - 2048.0f) / 2048.0f;
+
+ if (g_rx_n >= RX_SAMPLES_BIT) {
+ g_rx_n = 0;
+ rx_process_bit_window();
+ }
+}
+#else
+static void rx_goertzel_sample(void)
+{
+ /* TX-only boards: leave PIN_AF_RX_ADC as -1. */
+ g_carrier = false;
+}
+#endif
+
+void afsk_rx_poll(void)
+{
+ if (g_tx_busy) {
+ return;
+ }
+ rx_goertzel_sample();
+}
diff --git a/firmware/s1224/src/common/banner.c b/firmware/s1224/src/common/banner.c
new file mode 100644
index 0000000..21d1f3c
--- /dev/null
+++ b/firmware/s1224/src/common/banner.c
@@ -0,0 +1,82 @@
+/*
+ * Fixed boot banner — edit source only; not INI / CDC.
+ * Always printed once after USB CDC ready (main); quiet never suppresses it.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "banner.h"
+#include "version.h"
+#include "afsk_soft.h"
+#include <stdio.h>
+#include <string.h>
+
+/* Fixed CDC column width for centered serial look (40–48). */
+#define BANNER_WIDTH 44
+
+/** Display columns (UTF-8 code points ≈ 1 col; ASCII-safe). */
+static size_t banner_cols(const char *s)
+{
+ size_t cols = 0;
+ while (*s) {
+ unsigned char c = (unsigned char)*s++;
+ if ((c & 0x80u) == 0u) {
+ cols++;
+ } else if ((c & 0xE0u) == 0xC0u) {
+ if (*s) {
+ s++;
+ }
+ cols++;
+ } else if ((c & 0xF0u) == 0xE0u) {
+ if (*s) {
+ s++;
+ }
+ if (*s) {
+ s++;
+ }
+ cols++;
+ } else if ((c & 0xF8u) == 0xF0u) {
+ if (*s) {
+ s++;
+ }
+ if (*s) {
+ s++;
+ }
+ if (*s) {
+ s++;
+ }
+ cols++;
+ } else {
+ cols++;
+ }
+ }
+ return cols;
+}
+
+static void banner_line(const char *text)
+{
+ size_t cols = banner_cols(text);
+ size_t pad = 0;
+ if (cols < BANNER_WIDTH) {
+ pad = (BANNER_WIDTH - cols) / 2u;
+ }
+ for (size_t i = 0; i < pad; i++) {
+ putchar(' ');
+ }
+ fputs(text, stdout);
+ fputs("\r\n", stdout);
+}
+
+void banner_print(void)
+{
+ /* Fixed boot banner — edit source only */
+ char line[BANNER_WIDTH + 8];
+
+ snprintf(line, sizeof(line), "T-Modem-s1224 %s", S1224_FW_VERSION);
+ banner_line(line);
+
+ snprintf(line, sizeof(line), "USB KISS Half-TNC · AFSK %u",
+ (unsigned)afsk_bitrate());
+ banner_line(line);
+
+ banner_line("Non-Profit · GNU GPLv3 · AS-IS");
+ banner_line("https://github.com/ngteq");
+}
diff --git a/firmware/s1224/src/common/baud_config.c b/firmware/s1224/src/common/baud_config.c
new file mode 100644
index 0000000..8a94a19
--- /dev/null
+++ b/firmware/s1224/src/common/baud_config.c
@@ -0,0 +1,19 @@
+#include "baud_config.h"
+#include "afsk_soft.h"
+
+static const baud_profile_config_t cfg = {
+#if S1224_BITRATE == 300
+ .label = "s1224-300",
+#elif S1224_BITRATE == 1200
+ .label = "s1224-1200",
+#elif S1224_BITRATE == 2400
+ .label = "s1224-2400",
+#else
+ .label = "s1224",
+#endif
+};
+
+const baud_profile_config_t *baud_config_get(void)
+{
+ return &cfg;
+}
diff --git a/firmware/s1224/src/common/board_pins.c b/firmware/s1224/src/common/board_pins.c
new file mode 100644
index 0000000..d02ebd0
--- /dev/null
+++ b/firmware/s1224/src/common/board_pins.c
@@ -0,0 +1,47 @@
+/*
+ * Board GPIO setup — T-Modem-s1224 softmodem Pico.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "board_pins.h"
+#include "hardware/gpio.h"
+#include "hardware/i2c.h"
+
+void board_pins_init(void)
+{
+ gpio_init(PIN_AF_TX);
+ gpio_set_dir(PIN_AF_TX, GPIO_OUT);
+ gpio_put(PIN_AF_TX, 0);
+
+#if PIN_AF_RX_ADC >= 0
+ gpio_init(PIN_AF_RX_ADC);
+ gpio_set_dir(PIN_AF_RX_ADC, GPIO_IN);
+#endif
+
+ gpio_init(PIN_MODEM_RXD);
+ gpio_set_dir(PIN_MODEM_RXD, GPIO_IN);
+ gpio_pull_up(PIN_MODEM_RXD);
+
+ gpio_init(PIN_MODEM_CDT);
+ gpio_set_dir(PIN_MODEM_CDT, GPIO_IN);
+ gpio_pull_up(PIN_MODEM_CDT);
+
+ gpio_init(PIN_PTT_DRV);
+ gpio_set_dir(PIN_PTT_DRV, GPIO_OUT);
+ gpio_put(PIN_PTT_DRV, 0);
+
+ gpio_init(PIN_LED_PTT);
+ gpio_set_dir(PIN_LED_PTT, GPIO_OUT);
+ gpio_put(PIN_LED_PTT, 0);
+ gpio_init(PIN_LED_DCD);
+ gpio_set_dir(PIN_LED_DCD, GPIO_OUT);
+ gpio_put(PIN_LED_DCD, 0);
+ gpio_init(PIN_LED_TXRX);
+ gpio_set_dir(PIN_LED_TXRX, GPIO_OUT);
+ gpio_put(PIN_LED_TXRX, 0);
+
+ i2c_init(i2c0, I2C_LCD_BAUDRATE);
+ gpio_set_function(PIN_I2C_SDA, GPIO_FUNC_I2C);
+ gpio_set_function(PIN_I2C_SCL, GPIO_FUNC_I2C);
+ gpio_pull_up(PIN_I2C_SDA);
+ gpio_pull_up(PIN_I2C_SCL);
+}
diff --git a/firmware/s1224/src/common/config.c b/firmware/s1224/src/common/config.c
new file mode 100644
index 0000000..7c8f33c
--- /dev/null
+++ b/firmware/s1224/src/common/config.c
@@ -0,0 +1,422 @@
+/*
+ * INI defaults + flash overlay (last 4 KiB of Pico flash).
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "config.h"
+#include "hardware/flash.h"
+#include "hardware/sync.h"
+#include "pico/stdlib.h"
+#include <stdio.h>
+#include <string.h>
+#include <ctype.h>
+#include <stdlib.h>
+
+#define CFG_MAGIC0 'S'
+#define CFG_MAGIC1 '1'
+#define CFG_MAGIC2 '2'
+#define CFG_MAGIC3 '4'
+#define CFG_VERSION 1
+#define CFG_SECTOR_SIZE 4096
+/* Store config in the last flash sector */
+#define CFG_FLASH_OFFSET (PICO_FLASH_SIZE_BYTES - CFG_SECTOR_SIZE)
+
+typedef struct __attribute__((packed)) {
+ uint8_t magic[4];
+ uint8_t version;
+ uint8_t reserved;
+ uint16_t length;
+ uint16_t crc;
+ /* uint8_t data[length] follows */
+} cfg_hdr_t;
+
+static s1224_config_t g_cfg;
+
+static uint16_t crc16_ccitt(const uint8_t *data, size_t len)
+{
+ uint16_t crc = 0xFFFF;
+ for (size_t i = 0; i < len; i++) {
+ crc ^= (uint16_t)data[i] << 8;
+ for (int b = 0; b < 8; b++) {
+ if (crc & 0x8000) {
+ crc = (uint16_t)((crc << 1) ^ 0x1021);
+ } else {
+ crc <<= 1;
+ }
+ }
+ }
+ return crc;
+}
+
+static void copy_text(char *dst, const char *src)
+{
+ size_t n = strlen(src);
+ if (n >= S1224_TEXT_MAX) {
+ n = S1224_TEXT_MAX - 1;
+ }
+ memcpy(dst, src, n);
+ dst[n] = '\0';
+}
+
+void config_load_defaults(void)
+{
+ copy_text(g_cfg.starting, "s1224 is starting");
+ copy_text(g_cfg.ready, "s1224 is ready");
+ copy_text(g_cfg.fault, "s1224 is fault");
+ copy_text(g_cfg.sending, "s1224 is sending");
+ copy_text(g_cfg.signal, "s1224 signal");
+ copy_text(g_cfg.packet, "s1224 packet");
+ copy_text(g_cfg.power, "s1224 power");
+ copy_text(g_cfg.idle, "s1224 idle");
+ copy_text(g_cfg.callsign, "");
+ copy_text(g_cfg.device_id, "s1224");
+ g_cfg.quiet = true;
+ g_cfg.cdt_active_high = false;
+ g_cfg.packet_hold_ms = 500;
+ g_cfg.packet_edge_min = 4;
+ g_cfg.packet_window_ms = 50;
+}
+
+static char *trim(char *s)
+{
+ while (*s && isspace((unsigned char)*s)) {
+ s++;
+ }
+ char *e = s + strlen(s);
+ while (e > s && isspace((unsigned char)e[-1])) {
+ *--e = '\0';
+ }
+ return s;
+}
+
+static bool parse_bool(const char *v, bool *out)
+{
+ if (!v || !*v) {
+ return false;
+ }
+ if (v[0] == '1' || v[0] == 'y' || v[0] == 'Y' || v[0] == 't' || v[0] == 'T') {
+ *out = true;
+ return true;
+ }
+ if (v[0] == '0' || v[0] == 'n' || v[0] == 'N' || v[0] == 'f' || v[0] == 'F') {
+ *out = false;
+ return true;
+ }
+ return false;
+}
+
+bool config_set_kv(const char *key, const char *value)
+{
+ if (!key || !value) {
+ return false;
+ }
+ char kbuf[32];
+ size_t n = strlen(key);
+ if (n >= sizeof(kbuf)) {
+ return false;
+ }
+ for (size_t i = 0; i <= n; i++) {
+ kbuf[i] = (char)tolower((unsigned char)key[i]);
+ }
+
+ if (strcmp(kbuf, "starting") == 0) {
+ copy_text(g_cfg.starting, value);
+ return true;
+ }
+ if (strcmp(kbuf, "ready") == 0) {
+ copy_text(g_cfg.ready, value);
+ return true;
+ }
+ if (strcmp(kbuf, "fault") == 0) {
+ copy_text(g_cfg.fault, value);
+ return true;
+ }
+ if (strcmp(kbuf, "sending") == 0) {
+ copy_text(g_cfg.sending, value);
+ return true;
+ }
+ if (strcmp(kbuf, "signal") == 0) {
+ copy_text(g_cfg.signal, value);
+ return true;
+ }
+ if (strcmp(kbuf, "packet") == 0) {
+ copy_text(g_cfg.packet, value);
+ return true;
+ }
+ if (strcmp(kbuf, "power") == 0) {
+ copy_text(g_cfg.power, value);
+ return true;
+ }
+ if (strcmp(kbuf, "idle") == 0) {
+ copy_text(g_cfg.idle, value);
+ return true;
+ }
+ if (strcmp(kbuf, "callsign") == 0) {
+ copy_text(g_cfg.callsign, value);
+ return true;
+ }
+ if (strcmp(kbuf, "device_id") == 0) {
+ copy_text(g_cfg.device_id, value);
+ return true;
+ }
+ if (strcmp(kbuf, "quiet") == 0) {
+ bool b;
+ if (!parse_bool(value, &b)) {
+ return false;
+ }
+ g_cfg.quiet = b;
+ return true;
+ }
+ if (strcmp(kbuf, "cdt_active_high") == 0) {
+ bool b;
+ if (!parse_bool(value, &b)) {
+ return false;
+ }
+ g_cfg.cdt_active_high = b;
+ return true;
+ }
+ if (strcmp(kbuf, "packet_hold_ms") == 0) {
+ g_cfg.packet_hold_ms = (uint16_t)atoi(value);
+ return true;
+ }
+ if (strcmp(kbuf, "packet_edge_min") == 0) {
+ g_cfg.packet_edge_min = (uint16_t)atoi(value);
+ return true;
+ }
+ if (strcmp(kbuf, "packet_window_ms") == 0) {
+ g_cfg.packet_window_ms = (uint16_t)atoi(value);
+ return true;
+ }
+ return false;
+}
+
+bool config_get_kv(const char *key, char *buf, size_t buflen)
+{
+ if (!key || !buf || buflen == 0) {
+ return false;
+ }
+ char kbuf[32];
+ size_t n = strlen(key);
+ if (n >= sizeof(kbuf)) {
+ return false;
+ }
+ for (size_t i = 0; i <= n; i++) {
+ kbuf[i] = (char)tolower((unsigned char)key[i]);
+ }
+
+ const char *src = NULL;
+ char num[16];
+ if (strcmp(kbuf, "starting") == 0) {
+ src = g_cfg.starting;
+ } else if (strcmp(kbuf, "ready") == 0) {
+ src = g_cfg.ready;
+ } else if (strcmp(kbuf, "fault") == 0) {
+ src = g_cfg.fault;
+ } else if (strcmp(kbuf, "sending") == 0) {
+ src = g_cfg.sending;
+ } else if (strcmp(kbuf, "signal") == 0) {
+ src = g_cfg.signal;
+ } else if (strcmp(kbuf, "packet") == 0) {
+ src = g_cfg.packet;
+ } else if (strcmp(kbuf, "power") == 0) {
+ src = g_cfg.power;
+ } else if (strcmp(kbuf, "idle") == 0) {
+ src = g_cfg.idle;
+ } else if (strcmp(kbuf, "callsign") == 0) {
+ src = g_cfg.callsign;
+ } else if (strcmp(kbuf, "device_id") == 0) {
+ src = g_cfg.device_id;
+ } else if (strcmp(kbuf, "quiet") == 0) {
+ snprintf(num, sizeof(num), "%d", (int)g_cfg.quiet);
+ src = num;
+ } else if (strcmp(kbuf, "cdt_active_high") == 0) {
+ snprintf(num, sizeof(num), "%d", (int)g_cfg.cdt_active_high);
+ src = num;
+ } else if (strcmp(kbuf, "packet_hold_ms") == 0) {
+ snprintf(num, sizeof(num), "%u", (unsigned)g_cfg.packet_hold_ms);
+ src = num;
+ } else if (strcmp(kbuf, "packet_edge_min") == 0) {
+ snprintf(num, sizeof(num), "%u", (unsigned)g_cfg.packet_edge_min);
+ src = num;
+ } else if (strcmp(kbuf, "packet_window_ms") == 0) {
+ snprintf(num, sizeof(num), "%u", (unsigned)g_cfg.packet_window_ms);
+ src = num;
+ } else {
+ return false;
+ }
+ snprintf(buf, buflen, "%s", src);
+ return true;
+}
+
+static void parse_ini_text(char *text)
+{
+ char *save = NULL;
+ for (char *line = strtok_r(text, "\r\n", &save); line;
+ line = strtok_r(NULL, "\r\n", &save)) {
+ line = trim(line);
+ if (line[0] == '\0' || line[0] == '#' || line[0] == ';') {
+ continue;
+ }
+ if (line[0] == '[') {
+ continue; /* section headers ignored — flat keys */
+ }
+ char *eq = strchr(line, '=');
+ if (!eq) {
+ continue;
+ }
+ *eq = '\0';
+ char *k = trim(line);
+ char *v = trim(eq + 1);
+ (void)config_set_kv(k, v);
+ }
+}
+
+bool config_load_flash(void)
+{
+ const uint8_t *base = (const uint8_t *)(XIP_BASE + CFG_FLASH_OFFSET);
+ const cfg_hdr_t *hdr = (const cfg_hdr_t *)base;
+ if (hdr->magic[0] != CFG_MAGIC0 || hdr->magic[1] != CFG_MAGIC1 ||
+ hdr->magic[2] != CFG_MAGIC2 || hdr->magic[3] != CFG_MAGIC3) {
+ return false;
+ }
+ if (hdr->version != CFG_VERSION) {
+ return false;
+ }
+ if (hdr->length == 0 || hdr->length > (CFG_SECTOR_SIZE - sizeof(cfg_hdr_t) - 1)) {
+ return false;
+ }
+ const uint8_t *data = base + sizeof(cfg_hdr_t);
+ if (crc16_ccitt(data, hdr->length) != hdr->crc) {
+ return false;
+ }
+ static char tmp[CFG_SECTOR_SIZE];
+ if (hdr->length + 1 > sizeof(tmp)) {
+ return false;
+ }
+ memcpy(tmp, data, hdr->length);
+ tmp[hdr->length] = '\0';
+ parse_ini_text(tmp);
+ return true;
+}
+
+static size_t build_ini(char *out, size_t outsz)
+{
+ int n = snprintf(out, outsz,
+ "# s1224.ini — operator texts + terminal + sense\n"
+ "[texts]\n"
+ "starting=%s\n"
+ "ready=%s\n"
+ "fault=%s\n"
+ "sending=%s\n"
+ "signal=%s\n"
+ "packet=%s\n"
+ "power=%s\n"
+ "idle=%s\n"
+ "[terminal]\n"
+ "callsign=%s\n"
+ "device_id=%s\n"
+ "quiet=%d\n"
+ "[sense]\n"
+ "cdt_active_high=%d\n"
+ "packet_hold_ms=%u\n"
+ "packet_edge_min=%u\n"
+ "packet_window_ms=%u\n",
+ g_cfg.starting, g_cfg.ready, g_cfg.fault, g_cfg.sending,
+ g_cfg.signal, g_cfg.packet, g_cfg.power, g_cfg.idle,
+ g_cfg.callsign, g_cfg.device_id,
+ (int)g_cfg.quiet,
+ (int)g_cfg.cdt_active_high,
+ (unsigned)g_cfg.packet_hold_ms,
+ (unsigned)g_cfg.packet_edge_min,
+ (unsigned)g_cfg.packet_window_ms);
+ if (n < 0) {
+ return 0;
+ }
+ if ((size_t)n >= outsz) {
+ return outsz - 1;
+ }
+ return (size_t)n;
+}
+
+bool config_save_flash(void)
+{
+ char body[CFG_SECTOR_SIZE - sizeof(cfg_hdr_t)];
+ size_t len = build_ini(body, sizeof(body));
+ if (len == 0) {
+ return false;
+ }
+ uint8_t sector[CFG_SECTOR_SIZE];
+ memset(sector, 0xFF, sizeof(sector));
+ cfg_hdr_t *hdr = (cfg_hdr_t *)sector;
+ hdr->magic[0] = CFG_MAGIC0;
+ hdr->magic[1] = CFG_MAGIC1;
+ hdr->magic[2] = CFG_MAGIC2;
+ hdr->magic[3] = CFG_MAGIC3;
+ hdr->version = CFG_VERSION;
+ hdr->reserved = 0;
+ hdr->length = (uint16_t)len;
+ hdr->crc = crc16_ccitt((const uint8_t *)body, len);
+ memcpy(sector + sizeof(cfg_hdr_t), body, len);
+
+ uint32_t ints = save_and_disable_interrupts();
+ flash_range_erase(CFG_FLASH_OFFSET, CFG_SECTOR_SIZE);
+ flash_range_program(CFG_FLASH_OFFSET, sector, CFG_SECTOR_SIZE);
+ restore_interrupts(ints);
+ return true;
+}
+
+bool config_erase_flash(void)
+{
+ uint32_t ints = save_and_disable_interrupts();
+ flash_range_erase(CFG_FLASH_OFFSET, CFG_SECTOR_SIZE);
+ restore_interrupts(ints);
+ return true;
+}
+
+void config_dump_ini(void)
+{
+ char body[CFG_SECTOR_SIZE - sizeof(cfg_hdr_t)];
+ size_t len = build_ini(body, sizeof(body));
+ if (len == 0) {
+ printf("(empty)\r\n");
+ return;
+ }
+ /* print line by line for CDC */
+ for (size_t i = 0; i < len; i++) {
+ putchar(body[i]);
+ if (body[i] == '\n') {
+ putchar('\r');
+ }
+ }
+ if (len == 0 || body[len - 1] != '\n') {
+ printf("\r\n");
+ }
+}
+
+void config_init(void)
+{
+ config_load_defaults();
+ bool flash_ok = config_load_flash();
+ /* Extra chatter only when quiet=0 (fixed boot banner is separate / always on). */
+ if (config_unsolicited_ok()) {
+ if (flash_ok) {
+ printf("CONFIG: flash INI loaded\r\n");
+ } else {
+ printf("CONFIG: defaults (no flash overlay)\r\n");
+ }
+ }
+}
+
+const s1224_config_t *config_get(void)
+{
+ return &g_cfg;
+}
+
+s1224_config_t *config_mutable(void)
+{
+ return &g_cfg;
+}
+
+bool config_unsolicited_ok(void)
+{
+ return !g_cfg.quiet;
+}
diff --git a/firmware/s1224/src/common/crystal_steer.c b/firmware/s1224/src/common/crystal_steer.c
new file mode 100644
index 0000000..863b7b1
--- /dev/null
+++ b/firmware/s1224/src/common/crystal_steer.c
@@ -0,0 +1,17 @@
+/*
+ * Softmodem product — no crystal mux / no TCM3105 XTAL steering.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "crystal_steer.h"
+
+static bool g_applied;
+
+void crystal_steer_apply(void)
+{
+ g_applied = true;
+}
+
+bool crystal_steer_applied(void)
+{
+ return g_applied;
+}
diff --git a/firmware/s1224/src/common/display.c b/firmware/s1224/src/common/display.c
new file mode 100644
index 0000000..e2875be
--- /dev/null
+++ b/firmware/s1224/src/common/display.c
@@ -0,0 +1,187 @@
+/*
+ * HD44780 LCD via PCF8574 I2C backpack + USB mirror.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "display.h"
+#include "board_pins.h"
+#include "config.h"
+#include "status.h"
+#include "sense.h"
+#include "ptt.h"
+#include "hardware/i2c.h"
+#include "pico/stdlib.h"
+#include <stdio.h>
+#include <string.h>
+#include <stdint.h>
+
+/* PCF8574 bit map (common backpack): P0=RS P1=RW P2=E P3=BL P4-7=D4-D7 */
+#define LCD_RS 0x01
+#define LCD_RW 0x02
+#define LCD_EN 0x04
+#define LCD_BL 0x08
+
+static bool g_lcd;
+static uint8_t g_addr = I2C_LCD_ADDR_DEFAULT;
+static char g_last_l0[17];
+static char g_last_l1[17];
+
+static bool i2c_write_u8(uint8_t addr, uint8_t v)
+{
+ return i2c_write_blocking(i2c0, addr, &v, 1, false) >= 0;
+}
+
+static bool i2c_probe(uint8_t addr)
+{
+ uint8_t b = 0;
+ return i2c_write_blocking(i2c0, addr, &b, 1, false) >= 0;
+}
+
+static void lcd_write4(uint8_t nibble, uint8_t mode)
+{
+ uint8_t data = (uint8_t)((nibble & 0xF0) | LCD_BL | mode);
+ i2c_write_u8(g_addr, (uint8_t)(data | LCD_EN));
+ sleep_us(1);
+ i2c_write_u8(g_addr, (uint8_t)(data & (uint8_t)~LCD_EN));
+ sleep_us(50);
+}
+
+static void lcd_send(uint8_t value, uint8_t mode)
+{
+ lcd_write4((uint8_t)(value & 0xF0), mode);
+ lcd_write4((uint8_t)((value << 4) & 0xF0), mode);
+}
+
+static void lcd_cmd(uint8_t c)
+{
+ lcd_send(c, 0);
+}
+
+static void lcd_data(uint8_t c)
+{
+ lcd_send(c, LCD_RS);
+}
+
+static void lcd_set_cursor(uint8_t col, uint8_t row)
+{
+ static const uint8_t row_addr[] = {0x00, 0x40, 0x14, 0x54};
+ if (row > 1) {
+ row = 1;
+ }
+ lcd_cmd((uint8_t)(0x80 | (row_addr[row] + col)));
+}
+
+static void lcd_print_line(uint8_t row, const char *s)
+{
+ char buf[17];
+ size_t n = strlen(s);
+ if (n > 16) {
+ n = 16;
+ }
+ memset(buf, ' ', 16);
+ memcpy(buf, s, n);
+ buf[16] = '\0';
+ lcd_set_cursor(0, row);
+ for (int i = 0; i < 16; i++) {
+ lcd_data((uint8_t)buf[i]);
+ }
+}
+
+static bool lcd_init_seq(uint8_t addr)
+{
+ g_addr = addr;
+ sleep_ms(50);
+ lcd_write4(0x30, 0);
+ sleep_ms(5);
+ lcd_write4(0x30, 0);
+ sleep_us(150);
+ lcd_write4(0x30, 0);
+ sleep_us(150);
+ lcd_write4(0x20, 0); /* 4-bit */
+ lcd_cmd(0x28); /* 2 line, 5x8 */
+ lcd_cmd(0x0C); /* display on, cursor off */
+ lcd_cmd(0x06); /* entry mode */
+ lcd_cmd(0x01); /* clear */
+ sleep_ms(2);
+ return true;
+}
+
+void display_init(void)
+{
+ g_lcd = false;
+ g_last_l0[0] = '\0';
+ g_last_l1[0] = '\0';
+
+ uint8_t addrs[] = {0x27, 0x3F};
+ for (unsigned i = 0; i < sizeof(addrs); i++) {
+ if (i2c_probe(addrs[i])) {
+ if (lcd_init_seq(addrs[i])) {
+ g_lcd = true;
+ if (config_unsolicited_ok()) {
+ printf("LCD: HD44780 @ 0x%02X\r\n", addrs[i]);
+ }
+ return;
+ }
+ }
+ }
+ if (config_unsolicited_ok()) {
+ printf("LCD: none — USB status only\r\n");
+ }
+}
+
+bool display_present(void)
+{
+ return g_lcd;
+}
+
+void display_show(const char *line)
+{
+ if (!line) {
+ return;
+ }
+ if (g_lcd) {
+ lcd_print_line(0, line);
+ }
+}
+
+void display_update(void)
+{
+ const s1224_config_t *cfg = config_get();
+ char l0[17];
+ char l1[17];
+
+ s1224_state_t st = status_get();
+ if (st == S1224_STATE_FAULT) {
+ snprintf(l0, sizeof(l0), "%.16s", cfg->fault);
+ } else if (ptt_get()) {
+ snprintf(l0, sizeof(l0), "%.16s", cfg->sending);
+ } else if (st == S1224_STATE_STARTING) {
+ snprintf(l0, sizeof(l0), "%.16s", cfg->starting);
+ } else {
+ /* Power/ready on line 0 */
+ snprintf(l0, sizeof(l0), "%.16s", cfg->ready);
+ }
+
+ /* Line 1: Signal + Packet from sense */
+ bool sig = sense_signal_active();
+ bool pkt = sense_packet_active();
+ if (sig && pkt) {
+ snprintf(l1, sizeof(l1), "%.7s %.7s", cfg->signal, cfg->packet);
+ } else if (sig) {
+ snprintf(l1, sizeof(l1), "%.16s", cfg->signal);
+ } else if (pkt) {
+ snprintf(l1, sizeof(l1), "%.16s", cfg->packet);
+ } else {
+ snprintf(l1, sizeof(l1), "%.16s", cfg->power);
+ }
+
+ if (strcmp(l0, g_last_l0) != 0 || strcmp(l1, g_last_l1) != 0) {
+ strncpy(g_last_l0, l0, sizeof(g_last_l0));
+ strncpy(g_last_l1, l1, sizeof(g_last_l1));
+ g_last_l0[16] = '\0';
+ g_last_l1[16] = '\0';
+ if (g_lcd) {
+ lcd_print_line(0, l0);
+ lcd_print_line(1, l1);
+ }
+ }
+}
diff --git a/firmware/s1224/src/common/hdlc.c b/firmware/s1224/src/common/hdlc.c
new file mode 100644
index 0000000..3cee0da
--- /dev/null
+++ b/firmware/s1224/src/common/hdlc.c
@@ -0,0 +1,192 @@
+/*
+ * HDLC encode/decode — AX.25 bit order (LSB first), FCS-CCITT.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "hdlc.h"
+
+uint16_t hdlc_fcs_update(uint16_t fcs, uint8_t byte)
+{
+ fcs ^= (uint16_t)byte;
+ for (int i = 0; i < 8; i++) {
+ if (fcs & 1u) {
+ fcs = (uint16_t)((fcs >> 1) ^ 0x8408u);
+ } else {
+ fcs >>= 1;
+ }
+ }
+ return fcs;
+}
+
+uint16_t hdlc_fcs_final(uint16_t fcs)
+{
+ return (uint16_t)~fcs;
+}
+
+static bool push_bit(uint8_t *bits, size_t bits_cap, size_t *n, bool v)
+{
+ if (*n >= bits_cap) {
+ return false;
+ }
+ bits[*n] = v ? 1u : 0u;
+ (*n)++;
+ return true;
+}
+
+static bool stuff_bit(uint8_t *bits, size_t bits_cap, size_t *n,
+ unsigned *ones, bool bit)
+{
+ if (!push_bit(bits, bits_cap, n, bit)) {
+ return false;
+ }
+ if (bit) {
+ (*ones)++;
+ if (*ones == 5u) {
+ if (!push_bit(bits, bits_cap, n, false)) {
+ return false;
+ }
+ *ones = 0;
+ }
+ } else {
+ *ones = 0;
+ }
+ return true;
+}
+
+static bool emit_raw_byte(uint8_t *bits, size_t bits_cap, size_t *n, uint8_t b)
+{
+ for (int i = 0; i < 8; i++) {
+ if (!push_bit(bits, bits_cap, n, (b >> i) & 1u)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+static bool emit_stuffed_byte(uint8_t *bits, size_t bits_cap, size_t *n,
+ unsigned *ones, uint8_t b)
+{
+ for (int i = 0; i < 8; i++) {
+ if (!stuff_bit(bits, bits_cap, n, ones, (b >> i) & 1u)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+size_t hdlc_encode_bits(const uint8_t *info, size_t info_len,
+ uint8_t *bits, size_t bits_cap,
+ unsigned trailing_flags)
+{
+ if (!info || !bits || info_len > HDLC_MAX_INFO) {
+ return 0;
+ }
+ size_t n = 0;
+ unsigned ones = 0;
+
+ if (!emit_raw_byte(bits, bits_cap, &n, HDLC_FLAG)) {
+ return 0;
+ }
+
+ uint16_t fcs = HDLC_FCS_INIT;
+ for (size_t i = 0; i < info_len; i++) {
+ fcs = hdlc_fcs_update(fcs, info[i]);
+ if (!emit_stuffed_byte(bits, bits_cap, &n, &ones, info[i])) {
+ return 0;
+ }
+ }
+ fcs = hdlc_fcs_final(fcs);
+ if (!emit_stuffed_byte(bits, bits_cap, &n, &ones, (uint8_t)(fcs & 0xFFu))) {
+ return 0;
+ }
+ if (!emit_stuffed_byte(bits, bits_cap, &n, &ones, (uint8_t)((fcs >> 8) & 0xFFu))) {
+ return 0;
+ }
+
+ ones = 0;
+ unsigned flags = trailing_flags ? trailing_flags : 1u;
+ for (unsigned f = 0; f < flags; f++) {
+ if (!emit_raw_byte(bits, bits_cap, &n, HDLC_FLAG)) {
+ return 0;
+ }
+ }
+ return n;
+}
+
+void hdlc_rx_init(hdlc_rx_t *rx)
+{
+ rx->len = 0;
+ rx->ones = 0;
+ rx->in_frame = false;
+ rx->stuffing = false;
+ rx->byte_acc = 0;
+ rx->bit_count = 0;
+}
+
+bool hdlc_rx_bit(hdlc_rx_t *rx, bool bit, uint8_t *out, size_t *out_len)
+{
+ if (bit) {
+ rx->ones++;
+ } else {
+ if (rx->ones == 5u) {
+ /* stuffed 0 — discard */
+ rx->ones = 0;
+ return false;
+ }
+ if (rx->ones == 6u) {
+ /* flag */
+ if (rx->in_frame && rx->len >= 3) {
+ uint16_t fcs = HDLC_FCS_INIT;
+ for (size_t i = 0; i < rx->len; i++) {
+ fcs = hdlc_fcs_update(fcs, rx->buf[i]);
+ }
+ if (fcs == HDLC_FCS_GOOD) {
+ size_t info_len = rx->len - 2;
+ for (size_t i = 0; i < info_len; i++) {
+ out[i] = rx->buf[i];
+ }
+ *out_len = info_len;
+ rx->len = 0;
+ rx->bit_count = 0;
+ rx->byte_acc = 0;
+ rx->ones = 0;
+ rx->in_frame = true; /* abutting frames */
+ return true;
+ }
+ }
+ rx->len = 0;
+ rx->bit_count = 0;
+ rx->byte_acc = 0;
+ rx->ones = 0;
+ rx->in_frame = true;
+ return false;
+ }
+ if (rx->ones > 6u) {
+ /* abort */
+ rx->in_frame = false;
+ rx->len = 0;
+ rx->bit_count = 0;
+ rx->byte_acc = 0;
+ rx->ones = 0;
+ return false;
+ }
+ rx->ones = 0;
+ }
+
+ if (!rx->in_frame) {
+ return false;
+ }
+
+ rx->byte_acc |= (uint8_t)((bit ? 1u : 0u) << rx->bit_count);
+ rx->bit_count++;
+ if (rx->bit_count >= 8) {
+ if (rx->len < sizeof(rx->buf)) {
+ rx->buf[rx->len++] = rx->byte_acc;
+ } else {
+ rx->in_frame = false;
+ rx->len = 0;
+ }
+ rx->byte_acc = 0;
+ rx->bit_count = 0;
+ }
+ return false;
+}
diff --git a/firmware/s1224/src/common/kiss.c b/firmware/s1224/src/common/kiss.c
new file mode 100644
index 0000000..1d5d0e7
--- /dev/null
+++ b/firmware/s1224/src/common/kiss.c
@@ -0,0 +1,98 @@
+/*
+ * KISS encode/decode.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "kiss.h"
+
+void kiss_rx_init(kiss_rx_t *kx)
+{
+ kx->len = 0;
+ kx->in_frame = false;
+ kx->esc = false;
+}
+
+bool kiss_rx_byte(kiss_rx_t *kx, uint8_t b, uint8_t *out, size_t *out_len)
+{
+ if (b == KISS_FEND) {
+ if (kx->in_frame && kx->len >= 1) {
+ uint8_t cmd = kx->buf[0];
+ if ((cmd & 0x0Fu) == KISS_CMD_DATA && kx->len > 1) {
+ size_t n = kx->len - 1;
+ for (size_t i = 0; i < n; i++) {
+ out[i] = kx->buf[i + 1];
+ }
+ *out_len = n;
+ kx->len = 0;
+ kx->esc = false;
+ kx->in_frame = true;
+ return true;
+ }
+ }
+ kx->len = 0;
+ kx->esc = false;
+ kx->in_frame = true;
+ return false;
+ }
+ if (!kx->in_frame) {
+ return false;
+ }
+ if (kx->esc) {
+ if (b == KISS_TFEND) {
+ b = KISS_FEND;
+ } else if (b == KISS_TFESC) {
+ b = KISS_FESC;
+ }
+ kx->esc = false;
+ } else if (b == KISS_FESC) {
+ kx->esc = true;
+ return false;
+ }
+ if (kx->len < sizeof(kx->buf)) {
+ kx->buf[kx->len++] = b;
+ } else {
+ kx->in_frame = false;
+ kx->len = 0;
+ }
+ return false;
+}
+
+static bool push(uint8_t *dst, size_t cap, size_t *n, uint8_t b)
+{
+ if (*n >= cap) {
+ return false;
+ }
+ dst[(*n)++] = b;
+ return true;
+}
+
+static bool push_esc(uint8_t *dst, size_t cap, size_t *n, uint8_t b)
+{
+ if (b == KISS_FEND) {
+ return push(dst, cap, n, KISS_FESC) && push(dst, cap, n, KISS_TFEND);
+ }
+ if (b == KISS_FESC) {
+ return push(dst, cap, n, KISS_FESC) && push(dst, cap, n, KISS_TFESC);
+ }
+ return push(dst, cap, n, b);
+}
+
+size_t kiss_encode_data(const uint8_t *payload, size_t len,
+ uint8_t *dst, size_t dst_cap)
+{
+ size_t n = 0;
+ if (!push(dst, dst_cap, &n, KISS_FEND)) {
+ return 0;
+ }
+ if (!push_esc(dst, dst_cap, &n, KISS_CMD_DATA)) {
+ return 0;
+ }
+ for (size_t i = 0; i < len; i++) {
+ if (!push_esc(dst, dst_cap, &n, payload[i])) {
+ return 0;
+ }
+ }
+ if (!push(dst, dst_cap, &n, KISS_FEND)) {
+ return 0;
+ }
+ return n;
+}
diff --git a/firmware/s1224/src/common/main.c b/firmware/s1224/src/common/main.c
new file mode 100644
index 0000000..ae17585
--- /dev/null
+++ b/firmware/s1224/src/common/main.c
@@ -0,0 +1,61 @@
+/*
+ * T-Modem-s1224 — USB KISS half-TNC + softmodem AFSK (PWM TX / Goertzel RX).
+ * Three UF2 builds (300 / 1200 / 2400). No TCM3105 chip path.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "pico/stdlib.h"
+#include "board_pins.h"
+#include "status.h"
+#include "ptt.h"
+#include "modem_probe.h"
+#include "usb_cmd.h"
+#include "display.h"
+#include "crystal_steer.h"
+#include "baud_config.h"
+#include "config.h"
+#include "sense.h"
+#include "afsk_soft.h"
+#include "banner.h"
+#include "hardware/gpio.h"
+#include <stdio.h>
+
+int main(void)
+{
+ stdio_init_all();
+ sleep_ms(800);
+
+ board_pins_init();
+ ptt_init();
+ config_init();
+ display_init();
+ sense_init();
+ afsk_soft_init();
+ usb_cmd_init();
+ afsk_rx_set_callback(usb_cmd_on_rx_frame);
+
+ status_set(S1224_STATE_STARTING);
+ status_publish();
+ /* Fixed boot banner — always once after CDC ready; source-only (banner.c). */
+ banner_print();
+
+ modem_probe_result_t probe = modem_probe_run();
+ if (probe.ready) {
+ status_set(S1224_STATE_READY);
+ } else {
+ status_set(S1224_STATE_FAULT);
+ }
+ status_publish();
+
+ while (true) {
+ usb_cmd_poll();
+ afsk_rx_poll();
+ sense_poll();
+
+ gpio_put(PIN_LED_DCD, sense_signal_active() ? 1 : 0);
+ gpio_put(PIN_LED_PTT, ptt_get() ? 1 : 0);
+ gpio_put(PIN_LED_TXRX, sense_packet_active() ? 1 : 0);
+
+ status_tick();
+ sleep_us(50);
+ }
+}
diff --git a/firmware/s1224/src/common/modem_probe.c b/firmware/s1224/src/common/modem_probe.c
new file mode 100644
index 0000000..9624d32
--- /dev/null
+++ b/firmware/s1224/src/common/modem_probe.c
@@ -0,0 +1,22 @@
+/*
+ * Softmodem health — no chip/crystal checks.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "modem_probe.h"
+#include "config.h"
+#include "afsk_soft.h"
+#include <stdio.h>
+
+modem_probe_result_t modem_probe_run(void)
+{
+ modem_probe_result_t r = {0};
+ r.softmodem_ok = true;
+ r.af_tx_pwm = true;
+ r.ready = r.softmodem_ok && (afsk_bitrate() == 300u || afsk_bitrate() == 1200u || afsk_bitrate() == 2400u);
+
+ if (config_unsolicited_ok()) {
+ printf("PROBE softmodem=%d bitrate=%u -> %s\r\n",
+ (int)r.softmodem_ok, afsk_bitrate(), r.ready ? "READY" : "FAULT");
+ }
+ return r;
+}
diff --git a/firmware/s1224/src/common/ptt.c b/firmware/s1224/src/common/ptt.c
new file mode 100644
index 0000000..f54f221
--- /dev/null
+++ b/firmware/s1224/src/common/ptt.c
@@ -0,0 +1,31 @@
+/*
+ * PTT via Pico GPIO — host asserts over USB CDC (Type-A Path B), not softmodem.
+ * PIN_PTT_DRV = keying (required). PIN_LED_PTT = optional/spare TX LED mirror.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "ptt.h"
+#include "board_pins.h"
+#include "hardware/gpio.h"
+
+static bool g_ptt;
+
+void ptt_init(void)
+{
+ gpio_init(PIN_PTT_DRV);
+ gpio_set_dir(PIN_PTT_DRV, GPIO_OUT);
+ gpio_put(PIN_PTT_DRV, 0);
+ g_ptt = false;
+ gpio_put(PIN_LED_PTT, 0);
+}
+
+void ptt_set(bool on)
+{
+ g_ptt = on;
+ gpio_put(PIN_PTT_DRV, on ? 1 : 0);
+ gpio_put(PIN_LED_PTT, on ? 1 : 0);
+}
+
+bool ptt_get(void)
+{
+ return g_ptt;
+}
diff --git a/firmware/s1224/src/common/sense.c b/firmware/s1224/src/common/sense.c
new file mode 100644
index 0000000..376004d
--- /dev/null
+++ b/firmware/s1224/src/common/sense.c
@@ -0,0 +1,72 @@
+/*
+ * Sense for softmodem: carrier from demod stub + optional GP7 heuristic.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "sense.h"
+#include "board_pins.h"
+#include "config.h"
+#include "afsk_soft.h"
+#include "hardware/gpio.h"
+#include "pico/stdlib.h"
+#include <stdint.h>
+
+static bool g_signal;
+static bool g_packet;
+static bool g_flag;
+static absolute_time_t g_packet_until;
+static absolute_time_t g_window_start;
+static uint16_t g_edges;
+static bool g_last_rxd;
+static bool g_hold_valid;
+
+void sense_init(void)
+{
+ g_signal = false;
+ g_packet = false;
+ g_flag = false;
+ g_hold_valid = false;
+ g_window_start = get_absolute_time();
+ g_edges = 0;
+ g_last_rxd = gpio_get(PIN_MODEM_RXD);
+}
+
+void sense_poll(void)
+{
+ const s1224_config_t *cfg = config_get();
+
+ g_signal = afsk_rx_carrier_active();
+
+ bool rxd = gpio_get(PIN_MODEM_RXD);
+ if (rxd != g_last_rxd) {
+ g_edges++;
+ g_last_rxd = rxd;
+ }
+
+ uint32_t win_ms = cfg->packet_window_ms ? cfg->packet_window_ms : 50;
+ if (absolute_time_diff_us(g_window_start, get_absolute_time()) >=
+ (int64_t)win_ms * 1000) {
+ if (g_edges >= cfg->packet_edge_min) {
+ g_packet_until = make_timeout_time_ms(
+ cfg->packet_hold_ms ? cfg->packet_hold_ms : 500);
+ g_hold_valid = true;
+ }
+ g_edges = 0;
+ g_window_start = get_absolute_time();
+ }
+
+ if (g_hold_valid) {
+ if (time_reached(g_packet_until)) {
+ g_hold_valid = false;
+ g_packet = false;
+ g_flag = false;
+ } else {
+ g_packet = true;
+ }
+ } else {
+ g_packet = false;
+ }
+}
+
+bool sense_signal_active(void) { return g_signal; }
+bool sense_packet_active(void) { return g_packet; }
+bool sense_packet_flag(void) { return g_flag; }
diff --git a/firmware/s1224/src/common/status.c b/firmware/s1224/src/common/status.c
new file mode 100644
index 0000000..ca4df5c
--- /dev/null
+++ b/firmware/s1224/src/common/status.c
@@ -0,0 +1,78 @@
+/*
+ * Status from PTT + dual sense; texts from INI.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "status.h"
+#include "config.h"
+#include "display.h"
+#include "ptt.h"
+#include "sense.h"
+#include <stdio.h>
+
+static s1224_state_t g_state = S1224_STATE_STARTING;
+
+const char *s1224_state_string(s1224_state_t st)
+{
+ const s1224_config_t *cfg = config_get();
+ switch (st) {
+ case S1224_STATE_STARTING:
+ return cfg->starting;
+ case S1224_STATE_READY:
+ return cfg->ready;
+ case S1224_STATE_FAULT:
+ return cfg->fault;
+ case S1224_STATE_SENDING:
+ return cfg->sending;
+ case S1224_STATE_SIGNAL:
+ return cfg->signal;
+ case S1224_STATE_PACKET:
+ return cfg->packet;
+ default:
+ return cfg->fault;
+ }
+}
+
+void status_set(s1224_state_t st)
+{
+ g_state = st;
+}
+
+s1224_state_t status_get(void)
+{
+ return g_state;
+}
+
+void status_publish(void)
+{
+ const char *s = s1224_state_string(g_state);
+ if (config_unsolicited_ok()) {
+ printf("STATUS %s\r\n", s);
+ }
+ display_update();
+}
+
+void status_tick(void)
+{
+ if (g_state == S1224_STATE_FAULT || g_state == S1224_STATE_STARTING) {
+ display_update();
+ return;
+ }
+
+ s1224_state_t next;
+ if (ptt_get()) {
+ next = S1224_STATE_SENDING;
+ } else if (sense_packet_active()) {
+ next = S1224_STATE_PACKET;
+ } else if (sense_signal_active()) {
+ next = S1224_STATE_SIGNAL;
+ } else {
+ next = S1224_STATE_READY;
+ }
+
+ if (next != g_state) {
+ g_state = next;
+ status_publish();
+ } else {
+ display_update();
+ }
+}
diff --git a/firmware/s1224/src/common/usb_cmd.c b/firmware/s1224/src/common/usb_cmd.c
new file mode 100644
index 0000000..e4a5fc7
--- /dev/null
+++ b/firmware/s1224/src/common/usb_cmd.c
@@ -0,0 +1,267 @@
+/*
+ * USB CDC multiplexer: KISS binary (0xC0 frames) + ASCII service commands.
+ * Transparent Host↔Chip bit bridge stays abandoned — KISS half-TNC is the exception.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "usb_cmd.h"
+#include "pico/stdlib.h"
+#include "ptt.h"
+#include "status.h"
+#include "config.h"
+#include "sense.h"
+#include "board_pins.h"
+#include "afsk_soft.h"
+#include "kiss.h"
+#include "hardware/gpio.h"
+#include <stdio.h>
+#include <string.h>
+#include <ctype.h>
+
+#define LINE_MAX 96
+
+static char g_line[LINE_MAX];
+static unsigned g_len;
+static kiss_rx_t g_kiss;
+static uint8_t g_kiss_payload[KISS_MAX_PAYLOAD];
+
+static void trim_copy_upper_cmd(char *dst, size_t dstsz, const char *src)
+{
+ while (*src && isspace((unsigned char)*src)) {
+ src++;
+ }
+ size_t i = 0;
+ while (src[i] && i + 1 < dstsz) {
+ dst[i] = (char)toupper((unsigned char)src[i]);
+ i++;
+ }
+ dst[i] = '\0';
+ while (i > 0 && isspace((unsigned char)dst[i - 1])) {
+ dst[--i] = '\0';
+ }
+}
+
+static void handle_line(void)
+{
+ char cmd[LINE_MAX];
+ trim_copy_upper_cmd(cmd, sizeof(cmd), g_line);
+ if (cmd[0] == '\0') {
+ return;
+ }
+
+ if (strcmp(cmd, "PTT ON") == 0 || strcmp(cmd, "PTT 1") == 0) {
+ ptt_set(true);
+ status_tick();
+ printf("OK PTT ON\r\n");
+ return;
+ }
+ if (strcmp(cmd, "PTT OFF") == 0 || strcmp(cmd, "PTT 0") == 0) {
+ ptt_set(false);
+ status_tick();
+ printf("OK PTT OFF\r\n");
+ return;
+ }
+ if (strcmp(cmd, "STATUS") == 0) {
+ printf("STATUS state=%s ptt=%d signal=%d packet=%d flag=%d "
+ "carrier=%d bitrate=%u softmodem=1 kiss_half_tnc=1\r\n",
+ s1224_state_string(status_get()),
+ (int)ptt_get(),
+ (int)sense_signal_active(),
+ (int)sense_packet_active(),
+ (int)sense_packet_flag(),
+ (int)afsk_rx_carrier_active(),
+ afsk_bitrate());
+ return;
+ }
+ if (strcmp(cmd, "HELP") == 0) {
+ printf("Commands:\r\n");
+ printf(" PTT ON | PTT OFF\r\n");
+ printf(" STATUS | HELP | ID\r\n");
+ printf(" QUIET ON|OFF | VERBOSE ON|OFF\r\n");
+ printf(" INI | GET <key> | SET <key>=<value>\r\n");
+ printf(" SAVE | RELOAD | DEFAULTS\r\n");
+ printf("Dual protocol: byte 0xC0 → KISS; line ASCII → service.\r\n");
+ printf("Packet: use KISS-only (any host). Service cmds optional.\r\n");
+ printf("Boot banner always on (source). quiet=1 damps extra ASCII.\r\n");
+ printf("PTT = GP9 (auto during KISS TX; manual PTT ON/OFF kept).\r\n");
+ printf("Softmodem AFSK · S1224_BITRATE=%u (build: 300/1200/2400).\r\n",
+ afsk_bitrate());
+ printf("PWM AF_TX GP6 · ADC AF_RX GP26 · no chip modem · no baud switch.\r\n");
+ printf("INI: texts + callsign device_id quiet + sense keys\r\n");
+ return;
+ }
+ if (strcmp(cmd, "ID") == 0) {
+ const s1224_config_t *cfg = config_get();
+ printf("ID device_id=%s callsign=%s bitrate=%u quiet=%d\r\n",
+ cfg->device_id[0] ? cfg->device_id : "s1224",
+ cfg->callsign,
+ afsk_bitrate(),
+ (int)cfg->quiet);
+ return;
+ }
+ if (strcmp(cmd, "QUIET ON") == 0 || strcmp(cmd, "QUIET 1") == 0) {
+ config_mutable()->quiet = true;
+ printf("OK QUIET ON\r\n");
+ return;
+ }
+ if (strcmp(cmd, "QUIET OFF") == 0 || strcmp(cmd, "QUIET 0") == 0) {
+ config_mutable()->quiet = false;
+ printf("OK QUIET OFF\r\n");
+ return;
+ }
+ if (strcmp(cmd, "VERBOSE ON") == 0 || strcmp(cmd, "VERBOSE 1") == 0) {
+ config_mutable()->quiet = false;
+ printf("OK VERBOSE ON\r\n");
+ return;
+ }
+ if (strcmp(cmd, "VERBOSE OFF") == 0 || strcmp(cmd, "VERBOSE 0") == 0) {
+ config_mutable()->quiet = true;
+ printf("OK VERBOSE OFF\r\n");
+ return;
+ }
+ if (strcmp(cmd, "INI") == 0) {
+ config_dump_ini();
+ return;
+ }
+ if (strcmp(cmd, "SAVE") == 0) {
+ printf(config_save_flash() ? "OK SAVE\r\n" : "ERR SAVE\r\n");
+ return;
+ }
+ if (strcmp(cmd, "RELOAD") == 0) {
+ config_load_defaults();
+ if (config_load_flash()) {
+ printf("OK RELOAD flash\r\n");
+ } else {
+ printf("OK RELOAD defaults (no flash)\r\n");
+ }
+ status_publish();
+ return;
+ }
+ if (strcmp(cmd, "DEFAULTS") == 0) {
+ config_load_defaults();
+ printf("OK DEFAULTS (RAM; SAVE to persist)\r\n");
+ status_publish();
+ return;
+ }
+ if (strncmp(cmd, "GET ", 4) == 0) {
+ char key[32];
+ const char *p = g_line;
+ while (*p && isspace((unsigned char)*p)) {
+ p++;
+ }
+ if (strncasecmp(p, "GET", 3) == 0) {
+ p += 3;
+ }
+ while (*p && isspace((unsigned char)*p)) {
+ p++;
+ }
+ size_t i = 0;
+ while (*p && !isspace((unsigned char)*p) && i + 1 < sizeof(key)) {
+ key[i++] = *p++;
+ }
+ key[i] = '\0';
+ char val[S1224_TEXT_MAX];
+ if (config_get_kv(key, val, sizeof(val))) {
+ printf("OK %s=%s\r\n", key, val);
+ } else {
+ printf("ERR unknown key\r\n");
+ }
+ return;
+ }
+ if (strncmp(cmd, "SET ", 4) == 0) {
+ const char *p = g_line;
+ while (*p && isspace((unsigned char)*p)) {
+ p++;
+ }
+ if (strncasecmp(p, "SET", 3) == 0) {
+ p += 3;
+ }
+ while (*p && isspace((unsigned char)*p)) {
+ p++;
+ }
+ char tmp[LINE_MAX];
+ snprintf(tmp, sizeof(tmp), "%s", p);
+ char *eq = strchr(tmp, '=');
+ if (!eq) {
+ printf("ERR SET key=value\r\n");
+ return;
+ }
+ *eq = '\0';
+ char *k = tmp;
+ char *v = eq + 1;
+ while (*k && isspace((unsigned char)*k)) {
+ k++;
+ }
+ char *ke = k + strlen(k);
+ while (ke > k && isspace((unsigned char)ke[-1])) {
+ *--ke = '\0';
+ }
+ while (*v && isspace((unsigned char)*v)) {
+ v++;
+ }
+ char *ve = v + strlen(v);
+ while (ve > v && isspace((unsigned char)ve[-1])) {
+ *--ve = '\0';
+ }
+ if (config_set_kv(k, v)) {
+ printf("OK SET %s=%s (SAVE to persist)\r\n", k, v);
+ status_publish();
+ } else {
+ printf("ERR unknown key or bad value\r\n");
+ }
+ return;
+ }
+
+ printf("ERR unknown (try HELP)\r\n");
+}
+
+void usb_cmd_on_rx_frame(const uint8_t *info, size_t len)
+{
+ uint8_t frame[KISS_MAX_PAYLOAD + 16];
+ size_t n = kiss_encode_data(info, len, frame, sizeof(frame));
+ for (size_t i = 0; i < n; i++) {
+ putchar_raw((int)frame[i]);
+ }
+}
+
+void usb_cmd_poll(void)
+{
+ int c;
+ while ((c = getchar_timeout_us(0)) != PICO_ERROR_TIMEOUT) {
+ uint8_t b = (uint8_t)c;
+
+ /* KISS path: FEND or mid-frame */
+ if (b == KISS_FEND || g_kiss.in_frame) {
+ size_t plen = 0;
+ if (kiss_rx_byte(&g_kiss, b, g_kiss_payload, &plen)) {
+ if (plen > 0 && !afsk_tx_busy()) {
+ afsk_tx_frame(g_kiss_payload, plen);
+ }
+ }
+ continue;
+ }
+
+ if (c == '\r' || c == '\n') {
+ if (g_len > 0) {
+ g_line[g_len] = '\0';
+ handle_line();
+ g_len = 0;
+ }
+ continue;
+ }
+ if (c == 0x08 || c == 0x7f) {
+ if (g_len > 0) {
+ g_len--;
+ }
+ continue;
+ }
+ if (g_len + 1 < LINE_MAX && c >= 32 && c < 127) {
+ g_line[g_len++] = (char)c;
+ }
+ }
+}
+
+void usb_cmd_init(void)
+{
+ g_len = 0;
+ kiss_rx_init(&g_kiss);
+}
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com