From 83f9447cee09b5ab6cfd536eb49d8f680c8ee060 Mon Sep 17 00:00:00 2001 From: Tom Fanning Date: Sun, 12 Jul 2026 18:30:47 +0000 Subject: [PATCH 1/4] fw(netrom): drive obsolescence sweep on the NODES interval (axudp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap A (recon netrom.md §3): `NetRomService::sweep()` existed in core but no fw transport ever called it, so the routing table never aged — obsolescence never decremented, dead routes never purged, and the OBSMIN advertise-gate never engaged. The node advertised stale routes indefinitely. Wire a `next_sweep_at` cadence into the axudp beacon-tick loop that calls `netrom.sweep()` once per NODES interval, BEFORE origination so a broadcast reflects the freshly-aged table — the C# `NetRomService.OnInterval` order (Sweep() then BroadcastNodes()). Runs whether or not we originate: aging is a property of the table, and OBSINIT is calibrated to one broadcast period per decrement. First sweep is one interval after boot (never age a freshly flash-restored table immediately). Also restores the crate build: the merged core added `StatusReport` / `RssiReading` variants to `NinoTncInboundEvent`, which left kiss_serial's classify match non-exhaustive. Add the two arms (log-only telemetry; the full kiss_serial pump is rewritten in a later commit). Compile-validated only (no hardware): `cargo build --release --locked` + `cargo test --release --locked --no-run` both green. Sweep timing / actual purge behaviour needs bench validation with a live NODES feed. Co-Authored-By: Claude Code --- crates/ax25-node-fw/src/transports/axudp.rs | 23 +++++++++++++++++++ .../src/transports/kiss_serial.rs | 12 ++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/ax25-node-fw/src/transports/axudp.rs b/crates/ax25-node-fw/src/transports/axudp.rs index 906b624..fcac2d4 100644 --- a/crates/ax25-node-fw/src/transports/axudp.rs +++ b/crates/ax25-node-fw/src/transports/axudp.rs @@ -259,6 +259,12 @@ pub async fn task( netrom_cfg.nodes_interval_secs ); } + // Obsolescence sweep cadence: age/purge the routing table once per NODES + // interval (the C# `NetRomService.OnInterval` sweep — see NetRomService.cs). + // Runs whether or not WE originate: obsolescence aging is about the table, and + // OBSINIT is calibrated to one broadcast period per decrement. First sweep after + // one interval (never age a freshly booted/flash-restored table immediately). + let mut next_sweep_at = Instant::now() + nodes_interval; // The connected-mode session layer for this port + per-peer link state. let mut sessions = session::new_sessions(my_call); @@ -484,6 +490,23 @@ pub async fn task( } } + // Obsolescence sweep — age/purge routes once per NODES interval, + // BEFORE origination so a broadcast advertises the freshly-aged + // table (the C# `NetRomService.OnInterval` order: Sweep() then + // BroadcastNodes()). Drives the correctness path the recon flagged: + // without this, obsolescence never ages, dead routes never purge, + // and the OBSMIN advertise-gate never engages. + if Instant::now() >= next_sweep_at { + next_sweep_at = Instant::now() + nodes_interval; + let purged = netrom.sweep(); + if purged > 0 { + defmt::info!( + "netrom: obsolescence sweep purged {=usize} stale route(s)", + purged + ); + } + } + // NODES origination rides the beacon tick (10 s granularity is // plenty against minutes-scale intervals). if netrom_cfg.originate && Instant::now() >= next_nodes_at { diff --git a/crates/ax25-node-fw/src/transports/kiss_serial.rs b/crates/ax25-node-fw/src/transports/kiss_serial.rs index 5aec8d8..a17dc8d 100644 --- a/crates/ax25-node-fw/src/transports/kiss_serial.rs +++ b/crates/ax25-node-fw/src/transports/kiss_serial.rs @@ -122,6 +122,18 @@ pub async fn task( // probe. Log the learned callsign + press counter. defmt::info!("ninotnc air-test: seq={}", air_test.sequence_counter); } + NinoTncInboundEvent::StatusReport { status, .. } => { + // Periodic numeric =II: diagnostic-register beacon (or a GETALL + // reply) — modem telemetry, not an inbound AX.25 frame. + defmt::info!( + "ninotnc status: fw={=str}", + status.firmware_version_raw.as_str() + ); + } + NinoTncInboundEvent::RssiReading { rssi, .. } => { + // A GETRSSI reply — RX-audio level, not an inbound AX.25 frame. + defmt::info!("ninotnc rssi: {=i32} centi-dB", rssi.centi_db); + } NinoTncInboundEvent::Generic(InboundEvent::AckModeData { .. }) | NinoTncInboundEvent::Generic(InboundEvent::Unknown { .. }) => { // ACKMODE data / unrecognised — not part of the inbound AX.25 path. From 32ca495fe7a01952e899398997235952dadcc028 Mon Sep 17 00:00:00 2001 From: Tom Fanning Date: Sun, 12 Jul 2026 18:33:43 +0000 Subject: [PATCH 2/4] fw(netrom): originate NODES + sweep on the KISS-TCP (emulated-RF) path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap B (recon netrom.md §3): NODES origination was AXUDP/LAN-only, so the node was invisible in peers' nodes tables on the RF-shaped paths. Wire a `NetRomOriginator` into kiss_tcp's per-connection `serve` loop — build our broadcasts from the live routing table, wrap each payload as a UI frame (dest `NODES`, PID 0xCF), KISS-frame it and send — plus the obsolescence sweep on the same NODES interval (Gap A parity with axudp: age before advertise). The observe-tap was already present on this path. The header alias is the node mnemonic (`cfg.identity.alias`), falling back to the callsign base, matching the axudp originator. The routing table is per-connection (recreated on reconnect, as `netrom` already was), so a fresh connection announces itself with a header-only "I'm here" frame until it hears its first NODES broadcast — a pre-existing property of kiss_tcp's per-serve netrom, noted for bench validation. main.rs: pass `cfg.netrom` + `cfg.identity.alias` to the kiss_tcp spawn. The kiss_serial observe-tap + origination (also Gap B) is folded into the next commit, because on that transport it is inseparable from the task signature/spawn/pump changes. Compile-validated only (no hardware): `cargo build --release --locked` + `cargo test --release --locked --no-run` both green. On-air NODES visibility, sweep timing and the reconnect table-reset behaviour need bench validation against a live KISS-TCP/net-sim peer. Co-Authored-By: Claude Code --- crates/ax25-node-fw/src/main.rs | 2 + .../ax25-node-fw/src/transports/kiss_tcp.rs | 87 +++++++++++++++++-- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/crates/ax25-node-fw/src/main.rs b/crates/ax25-node-fw/src/main.rs index f6822ec..2c3193b 100644 --- a/crates/ax25-node-fw/src/main.rs +++ b/crates/ax25-node-fw/src/main.rs @@ -359,7 +359,9 @@ mod firmware { spawner.spawn(defmt::unwrap!(transports::kiss_tcp::task( stack, cfg.kiss_tcp.clone(), + cfg.netrom.clone(), cfg.identity.callsign, + cfg.identity.alias, ))); // --- mDNS: make the node discoverable as .local + _telnet._tcp --- diff --git a/crates/ax25-node-fw/src/transports/kiss_tcp.rs b/crates/ax25-node-fw/src/transports/kiss_tcp.rs index eb13ed5..2121b5b 100644 --- a/crates/ax25-node-fw/src/transports/kiss_tcp.rs +++ b/crates/ax25-node-fw/src/transports/kiss_tcp.rs @@ -14,16 +14,19 @@ //! stretch (HW-BRINGUP §6); the session hand-off is the supervisor seam. use ax25_node_core::kiss::{self, Decoder}; -use ax25_node_core::netrom::{ObserveOutcome, PortId}; +use ax25_node_core::netrom::wire::Alias; +use ax25_node_core::netrom::{ + NetRomOriginator, NetRomOriginatorOptions, ObserveOutcome, PortId, +}; use embassy_futures::select::{select, Either}; use embassy_net::tcp::TcpSocket; use embassy_net::Stack; -use embassy_time::{Duration, Ticker, Timer}; +use embassy_time::{Duration, Instant, Ticker, Timer}; use ax25_node_core::ax25::Callsign; -use crate::config::KissTcpConfig; +use crate::config::{KissTcpConfig, NetRomConfig}; use crate::session; use crate::transports::{call_str, parse_endpoint, tcp_write_all, ui_frame}; @@ -33,7 +36,13 @@ const BEACON_INTERVAL_SECS: u64 = 10; const KISS_PORT: u8 = 0; #[embassy_executor::task] -pub async fn task(stack: Stack<'static>, cfg: KissTcpConfig, my_call: Callsign) { +pub async fn task( + stack: Stack<'static>, + cfg: KissTcpConfig, + netrom_cfg: NetRomConfig, + my_call: Callsign, + node_alias: &'static str, +) { // §5: the endpoint is a LAN detail from the build env; absent ⇒ disabled. let Some(target) = cfg.target.and_then(parse_endpoint) else { defmt::info!("kiss-tcp: no KISS_TCP_TARGET set — disabled"); @@ -61,7 +70,7 @@ pub async fn task(stack: Stack<'static>, cfg: KissTcpConfig, my_call: Callsign) backoff_secs = 1; defmt::info!("kiss-tcp: connected to {:?}", target); - serve(&mut socket, my_call).await; + serve(&mut socket, my_call, &netrom_cfg, node_alias).await; socket.close(); let _ = socket.flush().await; @@ -71,11 +80,41 @@ pub async fn task(stack: Stack<'static>, cfg: KissTcpConfig, my_call: Callsign) } /// One connection: beacon ticker + read pump, until the peer goes away. -async fn serve(socket: &mut TcpSocket<'_>, my_call: Callsign) { +async fn serve( + socket: &mut TcpSocket<'_>, + my_call: Callsign, + netrom_cfg: &NetRomConfig, + node_alias: &str, +) { // The read-only NET/ROM tap — same FrameTraced-equivalent point as axudp. let mut netrom = session::new_netrom(); let port_id = PortId::from_str_lossy("kiss-tcp"); + // NODES origination over this KISS port (Gap B — the node becomes VISIBLE on + // the emulated-RF channel, not AXUDP-only). Mirrors the axudp originator: the + // header alias is the node's mnemonic, falling back to the callsign base when + // empty. The routing table is per-connection here (recreated on each reconnect, + // like `netrom`), so a fresh connection announces itself with a header-only + // "I'm here" frame until it hears its first NODES broadcast. + let originator = NetRomOriginator::new(NetRomOriginatorOptions { + enabled: netrom_cfg.originate, + alias: Some(Alias::from_str_lossy(node_alias)), + node_call: Some(my_call), + obsolete_minimum: None, + }); + let nodes_interval = Duration::from_secs(netrom_cfg.nodes_interval_secs as u64); + let mut next_nodes_at = Instant::now(); // announce on the first tick + // Obsolescence sweep on the same NODES interval (Gap A parity with axudp): age + // BEFORE origination so a broadcast reflects the freshly-aged table. First sweep + // after one interval. + let mut next_sweep_at = Instant::now() + nodes_interval; + if netrom_cfg.originate { + defmt::info!( + "kiss-tcp: NODES origination on, every {=u32}s", + netrom_cfg.nodes_interval_secs + ); + } + let mut decoder = Decoder::new(); let mut buf = [0u8; 512]; let mut ticker = Ticker::every(Duration::from_secs(BEACON_INTERVAL_SECS)); @@ -104,6 +143,42 @@ async fn serve(socket: &mut TcpSocket<'_>, my_call: Callsign) { "kiss-tcp: beacon sent ({=usize} KISS bytes)", kiss_bytes.len() ); + + // Obsolescence sweep — age/purge once per NODES interval, before + // origination (the C# `NetRomService.OnInterval` order). + if Instant::now() >= next_sweep_at { + next_sweep_at = Instant::now() + nodes_interval; + let purged = netrom.sweep(); + if purged > 0 { + defmt::info!( + "kiss-tcp: obsolescence sweep purged {=usize} stale route(s)", + purged + ); + } + } + + // NODES origination — build our broadcasts from the live table, wrap + // each as a UI frame (dest NODES, PID 0xCF), KISS-frame it, and send. + if netrom_cfg.originate && Instant::now() >= next_nodes_at { + next_nodes_at = Instant::now() + nodes_interval; + let payloads = originator.broadcast_nodes(netrom.table()); + let dest = NetRomOriginator::nodes_destination(); + let mut sent = 0usize; + for payload in &payloads { + let frame = ui_frame(my_call, dest, NetRomOriginator::PID, payload); + let Some(kiss_bytes) = + kiss::encode(KISS_PORT, kiss::Command::Data, &frame.encode()) + else { + defmt::warn!("kiss-tcp: NODES encode failed"); + continue; + }; + if !tcp_write_all(socket, &kiss_bytes).await { + return; + } + sent += 1; + } + defmt::info!("kiss-tcp: NODES broadcast sent ({=usize} frame(s))", sent); + } } Either::Second(Ok(0)) => return, // EOF Either::Second(Ok(n)) => { From 9aa9353ef8de4afe9c952652fa04347a5d961799 Mon Sep 17 00:00:00 2001 From: Tom Fanning Date: Sun, 12 Jul 2026 18:39:04 +0000 Subject: [PATCH 3/4] fw(kiss-serial): fill the NinoTNC pump + NODES on RF, and spawn it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services Gap D + netrom Gap B: the KISS-over-UART transport was stubbed (`let _ = ax25;` dropped every inbound frame) and never spawned. Fill it, mirroring the KISS-TCP transport precisely: - Pump: `select(modem.read_frame(), ticker.next())` — wake on an inbound KISS frame or the periodic tick. `read_frame` is cancel-safe (its only await is the UART read; decode state lives in the modem), so a tick that pre-empts the read loses no buffered bytes. - Inbound: classify with NinoTNC awareness, then run the read-only NET/ROM tap (`session::observe_inbound`) on every AX.25 frame BEFORE any address filter, so NODES broadcasts are heard on real RF. Connected-mode session routing stays the deferred supervisor seam (as kiss_tcp leaves it). - Outbound drain to the UART on the tick: beacon UI frame, obsolescence sweep (Gap A parity), and NODES origination (Gap B) built from the live table. Wiring: - main.rs: spawn `kiss_serial::task` past the callsign gate on UART1 GP20/21, passing netrom config + callsign + alias. - config.rs: extend `KissSerialConfig` with `startup_mode: Option` (a boot NinoTNC SETHW mode, RAM-only; `None` default preserves behaviour; from `NINOTNC_MODE`). COMPILE-VALIDATED ONLY (no hardware): `cargo build --release --locked` + `cargo test --release --locked --no-run` both green. Everything from the UART byte exchange onward — SETHW at boot, the read pump under real KISS/NinoTNC traffic, beacon/NODES TX timing, cancel-safety of read_frame under the live UART — needs bench validation with a NinoTNC on GP20/21 @ 57600. Co-Authored-By: Claude Code --- crates/ax25-node-fw/src/config.rs | 10 +- crates/ax25-node-fw/src/main.rs | 18 +- .../src/transports/kiss_serial.rs | 234 +++++++++++++----- 3 files changed, 200 insertions(+), 62 deletions(-) diff --git a/crates/ax25-node-fw/src/config.rs b/crates/ax25-node-fw/src/config.rs index 0ca3a93..5779c1f 100644 --- a/crates/ax25-node-fw/src/config.rs +++ b/crates/ax25-node-fw/src/config.rs @@ -88,6 +88,11 @@ pub struct KissTcpConfig { #[derive(Clone)] pub struct KissSerialConfig { pub baud: u32, + /// Optional NinoTNC operating mode to set at boot via KISS SETHW (RAM-only — + /// spares flash). `None` (the default) leaves the modem's own configured mode + /// untouched. From the build env `NINOTNC_MODE`; a §policy knob so the node can + /// force a known modem mode at startup. Values > 15 are rejected by SETHW. + pub startup_mode: Option, } /// Telnet command console (capability 4). @@ -140,7 +145,10 @@ pub fn load() -> NodeConfig { kiss_tcp: KissTcpConfig { target: option_env!("KISS_TCP_TARGET"), }, - kiss_serial: KissSerialConfig { baud: 57600 }, + kiss_serial: KissSerialConfig { + baud: 57600, + startup_mode: option_env!("NINOTNC_MODE").and_then(|s| s.parse::().ok()), + }, telnet: TelnetConfig { port: 8023 }, netrom: NetRomConfig { originate: true, diff --git a/crates/ax25-node-fw/src/main.rs b/crates/ax25-node-fw/src/main.rs index 2c3193b..446ffb5 100644 --- a/crates/ax25-node-fw/src/main.rs +++ b/crates/ax25-node-fw/src/main.rs @@ -373,7 +373,23 @@ mod firmware { }, ))); - // GATE 6+ returns kiss_serial (needs a NinoTNC) + the session supervisor. + // --- GATE 6 (HW-BRINGUP.md §4): KISS-over-UART to a NinoTNC (capability 3). + // UART1 on GP20(TX)/GP21(RX), the NinoBLE Rev5 link pins. The task always + // spawns (there is no build-env target for a physical UART); it reads + // nothing until a NinoTNC is wired, but the read-only NET/ROM tap, NODES + // origination, obsolescence sweep and beacon all run. COMPILE-VALIDATED + // ONLY — the live exchange needs the NinoTNC on the bench (no hardware here). + spawner.spawn(defmt::unwrap!(transports::kiss_serial::task( + p.UART1, + p.PIN_20, + p.PIN_21, + cfg.kiss_serial.clone(), + cfg.netrom.clone(), + cfg.identity.callsign, + cfg.identity.alias, + ))); + + // The session supervisor (shared Sessions across transports) is the next seam. let mut ticker = Ticker::every(Duration::from_secs(10)); loop { diff --git a/crates/ax25-node-fw/src/transports/kiss_serial.rs b/crates/ax25-node-fw/src/transports/kiss_serial.rs index a17dc8d..c26dbf4 100644 --- a/crates/ax25-node-fw/src/transports/kiss_serial.rs +++ b/crates/ax25-node-fw/src/transports/kiss_serial.rs @@ -1,4 +1,4 @@ -#![allow(dead_code)] // built + type-checked; the task is spawned at Gate 6 (a NinoTNC on GP20/21) +#![allow(dead_code)] // spawned now; some core modem setters (params/ackmode) stay unused until a session supervisor drives outbound //! Capability 3 — KISS-over-serial to a NinoTNC. //! @@ -19,24 +19,38 @@ //! NinoBLE Rev5 carrier board (docs/HARDWARE-NINOBLE.md), our reference hardware. //! //! The UART layer below is real (embassy-rp 0.10 `BufferedUart`), so this module -//! compiles and is type-checked by CI. HARDWARE-GATED for *running*: the live -//! exchange needs a physical NinoTNC on GP20/21 — not present on the bare-Pico -//! bench rig, so the task is not spawned yet (HW-BRINGUP Gate 6). +//! compiles and is type-checked by CI. The task is spawned (mirroring the KISS-TCP +//! transport: read-only NET/ROM tap + NODES origination + obsolescence sweep + +//! beacon), but is HARDWARE-GATED for *running*: the live exchange needs a physical +//! NinoTNC on GP20/21 — not present on the bare-Pico bench rig (HW-BRINGUP Gate 6). +//! Everything here is COMPILE-VALIDATED ONLY until that hardware is attached. +use ax25_node_core::ax25::{Callsign, PID_NO_LAYER3}; use ax25_node_core::kiss::ninotnc::{self, NinoTncInboundEvent}; use ax25_node_core::kiss::serial::ByteStream; use ax25_node_core::kiss::{classify::InboundEvent, SerialKissModem}; +use ax25_node_core::netrom::wire::Alias; +use ax25_node_core::netrom::{ + NetRomOriginator, NetRomOriginatorOptions, ObserveOutcome, PortId, +}; +use embassy_futures::select::{select, Either}; use embassy_rp::bind_interrupts; use embassy_rp::peripherals::{PIN_20, PIN_21, UART1}; use embassy_rp::uart::{ BufferedInterruptHandler, BufferedUart, Config as UartConfig, Error as UartError, }; use embassy_rp::Peri; +use embassy_time::{Duration, Instant, Ticker, Timer}; use embedded_io_async::{Read, Write}; use static_cell::StaticCell; -use crate::config::KissSerialConfig; +use crate::config::{KissSerialConfig, NetRomConfig}; +use crate::session; +use crate::transports::{call_str, ui_frame}; + +/// Seconds between beacon UI frames (mirrors the KISS-TCP transport's beacon). +const BEACON_INTERVAL_SECS: u64 = 10; bind_interrupts!(struct Irqs { UART1_IRQ => BufferedInterruptHandler; @@ -77,6 +91,9 @@ pub async fn task( tx_pin: Peri<'static, PIN_20>, rx_pin: Peri<'static, PIN_21>, cfg: KissSerialConfig, + netrom_cfg: NetRomConfig, + my_call: Callsign, + node_alias: &'static str, ) { defmt::info!( "kiss-serial: UART1 GP20/21 @ {} baud (NinoTNC direct UART)", @@ -84,73 +101,170 @@ pub async fn task( ); let uart = configure_uart(uart, tx_pin, rx_pin, cfg.baud); - let mut modem = SerialKissModem::new(UartByteStream::new(uart)); // Optionally drive the NinoTNC into a known mode at startup (RAM-only, sparing - // flash). The C# node does this via NinoTncSerialPort.SetModeAsync. Example: - // let _ = ninotnc::sethw::build_kiss_frame_into(&mut buf, 6, false, 0) - // .map(|n| /* modem write */); - // Left to config policy; the helper is wired below so the import is load-bearing. - let _ = ninotnc::sethw::build_payload_byte; - - // The read pump: pull each inbound KISS frame and classify it with NinoTNC - // awareness, then route. This mirrors NinoTncSerialPort.DispatchFramesAsync. + // flash) — the C# `NinoTncSerialPort.SetModeAsync` equivalent, gated on config + // policy. `None` (the default) leaves the modem's own mode untouched. + if let Some(mode) = cfg.startup_mode { + match modem.set_mode(mode, false).await { + Ok(()) => defmt::info!("kiss-serial: NinoTNC mode set to {=u8} (RAM-only)", mode), + Err(e) => { + defmt::warn!("kiss-serial: set mode failed: {}", defmt::Debug2Format(&e)) + } + } + } + + // Read-only NET/ROM tap + NODES origination + obsolescence sweep — the same + // wiring the KISS-TCP transport uses, now over real RF (Gap A + Gap B). Each + // transport owns its own routing table (the single-transport-ownership model; + // the shared session/routing supervisor seam is deferred). + let mut netrom = session::new_netrom(); + let port_id = PortId::from_str_lossy("kiss-serial"); + let originator = NetRomOriginator::new(NetRomOriginatorOptions { + enabled: netrom_cfg.originate, + alias: Some(Alias::from_str_lossy(node_alias)), + node_call: Some(my_call), + obsolete_minimum: None, + }); + let nodes_interval = Duration::from_secs(netrom_cfg.nodes_interval_secs as u64); + let mut next_nodes_at = Instant::now(); // announce on the first tick + let mut next_sweep_at = Instant::now() + nodes_interval; + if netrom_cfg.originate { + defmt::info!( + "kiss-serial: NODES origination on, every {=u32}s", + netrom_cfg.nodes_interval_secs + ); + } + + let mut ticker = Ticker::every(Duration::from_secs(BEACON_INTERVAL_SECS)); + let mut src_buf = [0u8; 16]; + let mut dst_buf = [0u8; 16]; + + // The pump: wake on either an inbound KISS frame or the periodic tick. + // `SerialKissModem::read_frame` is cancel-safe — its only await is the UART + // read, and the decode state lives in the modem — so dropping it when the + // ticker wins loses no buffered bytes. loop { - match modem.read_frame().await { - Ok(Some(frame)) => match ninotnc::classify(&frame) { - NinoTncInboundEvent::Generic(InboundEvent::Ax25 { ax25, .. }) => { - // READ-ONLY NET/ROM TAP — every frame, BEFORE the address filter, - // so NODES broadcasts (dest "NODES", not us) are heard. Then the - // normal address-filtered routing to a session (same seam as the - // kiss_tcp / axudp transports). - // session::observe_inbound(&mut netrom, &ax25, my_call, PortId::from_str_lossy("kiss-serial")); - // session::deliver_kiss(ax25).await; - let _ = ax25; + match select(modem.read_frame(), ticker.next()).await { + Either::First(read) => match read { + Ok(Some(frame)) => match ninotnc::classify(&frame) { + NinoTncInboundEvent::Generic(InboundEvent::Ax25 { ax25, .. }) => { + // READ-ONLY NET/ROM TAP — every frame, BEFORE any address + // filter, so NODES broadcasts (dest "NODES", not us) are heard. + // The same FrameTraced-equivalent point as axudp / kiss_tcp. + let outcome = + session::observe_inbound(&mut netrom, &ax25, my_call, port_id); + if let ObserveOutcome::Ingested { .. } = outcome { + defmt::info!( + "kiss-serial: NODES broadcast ingested ({=u32} destinations known)", + netrom.destination_count() as u32 + ); + } + defmt::info!( + "kiss-serial: rx {=str} -> {=str} ctl={=u8:#04x} info={=usize}B", + call_str(&ax25.source.callsign, &mut src_buf), + call_str(&ax25.destination.callsign, &mut dst_buf), + ax25.control, + ax25.info.len(), + ); + // Address-filtered connected-mode session routing: the + // session-supervisor seam (the same deferred point kiss_tcp + // leaves — the SDL engine is host-tested in core; only the + // socket/UART wiring is hardware-gated). + } + NinoTncInboundEvent::TxTestDiagnostic { diagnostic, .. } => { + // The on-demand modem diagnostic (button pressed on THIS + // NinoTNC): firmware version, running mode, counters. + defmt::info!( + "ninotnc tx-test: fw={=str} running-mode={:?}", + diagnostic.firmware_version_raw.as_str(), + diagnostic.running_mode.map(|m| m.mode) + ); + } + NinoTncInboundEvent::AirTest { air_test, .. } => { + // Over-air TX-Test from ANOTHER NinoTNC operator — a + // link-quality probe. Log the learned callsign + press counter. + defmt::info!("ninotnc air-test: seq={}", air_test.sequence_counter); + } + NinoTncInboundEvent::StatusReport { status, .. } => { + // Periodic numeric =II: diagnostic-register beacon (or a + // GETALL reply) — modem telemetry, not an inbound AX.25 frame. + defmt::info!( + "ninotnc status: fw={=str}", + status.firmware_version_raw.as_str() + ); + } + NinoTncInboundEvent::RssiReading { rssi, .. } => { + // A GETRSSI reply — RX-audio level, not an inbound AX.25 frame. + defmt::info!("ninotnc rssi: {=i32} centi-dB", rssi.centi_db); + } + NinoTncInboundEvent::Generic(InboundEvent::AckModeData { .. }) + | NinoTncInboundEvent::Generic(InboundEvent::Unknown { .. }) => { + // ACKMODE data / unrecognised — not part of the inbound AX.25 path. + } + }, + // EOF / link-down: a buffered UART doesn't really "close", but on a + // read error or zero-read we yield and retry rather than spin. + Ok(None) => Timer::after_millis(10).await, + Err(e) => { + defmt::warn!("kiss-serial read error: {}", defmt::Debug2Format(&e)); + Timer::after_millis(100).await; } - NinoTncInboundEvent::TxTestDiagnostic { diagnostic, .. } => { - // The on-demand modem diagnostic (button pressed on THIS NinoTNC): - // firmware version, running mode, counters. Surface to the console. - defmt::info!( - "ninotnc tx-test: fw={=str} running-mode={:?}", - diagnostic.firmware_version_raw.as_str(), - diagnostic.running_mode.map(|m| m.mode) - ); + }, + Either::Second(()) => { + // The periodic tick drains outbound to the UART: beacon, then (on the + // NODES interval) the obsolescence sweep and NODES origination. This + // mirrors the KISS-TCP tick branch; a session supervisor will later + // add connected-mode I-frame outbound through the same `modem`. + let beacon = ui_frame( + my_call, + Callsign::parse("IDENT").expect("static"), + PID_NO_LAYER3, + b"pico-node KISS-serial beacon (HW-BRINGUP Gate 6)", + ); + if let Err(e) = modem.send_frame(&beacon.encode()).await { + defmt::warn!("kiss-serial: beacon send failed: {}", defmt::Debug2Format(&e)); } - NinoTncInboundEvent::AirTest { air_test, .. } => { - // Over-air TX-Test from ANOTHER NinoTNC operator — a link-quality - // probe. Log the learned callsign + press counter. - defmt::info!("ninotnc air-test: seq={}", air_test.sequence_counter); + + // Obsolescence sweep — age/purge once per NODES interval, before + // origination (the C# `NetRomService.OnInterval` order). + if Instant::now() >= next_sweep_at { + next_sweep_at = Instant::now() + nodes_interval; + let purged = netrom.sweep(); + if purged > 0 { + defmt::info!( + "kiss-serial: obsolescence sweep purged {=usize} stale route(s)", + purged + ); + } } - NinoTncInboundEvent::StatusReport { status, .. } => { - // Periodic numeric =II: diagnostic-register beacon (or a GETALL - // reply) — modem telemetry, not an inbound AX.25 frame. + + // NODES origination — build our broadcasts from the live table, wrap + // each as a UI frame (dest NODES, PID 0xCF), and send it to the UART. + if netrom_cfg.originate && Instant::now() >= next_nodes_at { + next_nodes_at = Instant::now() + nodes_interval; + let payloads = originator.broadcast_nodes(netrom.table()); + let dest = NetRomOriginator::nodes_destination(); + let mut sent = 0usize; + for payload in &payloads { + let frame = ui_frame(my_call, dest, NetRomOriginator::PID, payload); + if let Err(e) = modem.send_frame(&frame.encode()).await { + defmt::warn!( + "kiss-serial: NODES send failed: {}", + defmt::Debug2Format(&e) + ); + continue; + } + sent += 1; + } defmt::info!( - "ninotnc status: fw={=str}", - status.firmware_version_raw.as_str() + "kiss-serial: NODES broadcast sent ({=usize} frame(s))", + sent ); } - NinoTncInboundEvent::RssiReading { rssi, .. } => { - // A GETRSSI reply — RX-audio level, not an inbound AX.25 frame. - defmt::info!("ninotnc rssi: {=i32} centi-dB", rssi.centi_db); - } - NinoTncInboundEvent::Generic(InboundEvent::AckModeData { .. }) - | NinoTncInboundEvent::Generic(InboundEvent::Unknown { .. }) => { - // ACKMODE data / unrecognised — not part of the inbound AX.25 path. - } - }, - // EOF / link-down: a buffered UART doesn't really "close", but on a read - // error or zero-read we yield and retry rather than spin. - Ok(None) => embassy_time::Timer::after_millis(10).await, - Err(e) => { - defmt::warn!("kiss-serial read error: {}", defmt::Debug2Format(&e)); - embassy_time::Timer::after_millis(100).await; } } - // Outbound is symmetric: the session layer hands an AX.25 body to - // modem.send_frame(&ax25_bytes).await - // (or modem.send_kiss(Command::AckMode, &payload) for ACKMODE), with the - // SETHW / parameter setters available on the same `modem`. } } From abfeeccfe5437f129832e24ff2f5912bf7b54ebe Mon Sep 17 00:00:00 2001 From: Tom Fanning Date: Sun, 12 Jul 2026 18:43:11 +0000 Subject: [PATCH 4/4] fw(radio): add the Tait CCDI transport on a second UART MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `transports/tait_ccdi.rs` — an Embassy task owning a SECOND UART (UART0 GP0/GP1, distinct from the NinoTNC KISS link on UART1) that drives the core `radio::tait::driver::TaitCcdiRadio` CCDI driver. Mirrors kiss_serial's structure precisely (reuses its `UartByteStream` byte-source seam and the same `configure_uart` / `bind_interrupts` shape; a UART0 interrupt binding). The drive loop: - enables unsolicited PROGRESS output at boot (FUNCTION 0/4) — required before carrier-sense (DCD) / PTT edges are reported; - optionally retunes to a configured channel at boot (GO_TO_CHANNEL); - polls RSSI (integer tenths-of-dBm) on a ticker, then drains the carrier-sense / PTT / SDM PROGRESS edges the driver demuxes out of each transaction (and maintains `channel_busy` from). Self-quiets on NoResponse so an absent radio doesn't spam warnings. Wiring: `transports/mod.rs` registers the module; `config.rs` adds `TaitConfig` (baud default = core `tait::DEFAULT_BAUD` 28800, optional boot channel, RSSI poll cadence) via `TAIT_BAUD` / `TAIT_CHANNEL`; `main.rs` spawns it on UART0/GP0/GP1. COMPILE-VALIDATED ONLY (no hardware): `cargo build --release --locked` + `cargo test --release --locked --no-run` both green; no new deps (Cargo.lock unchanged). Needs bench validation with a Tait TM8100/TM8200 on GP0/GP1: the CCDI transact/demux under a live radio, PROGRESS-enable acknowledgement, RSSI values, carrier-sense/PTT edge timing, and the UART0 pin/baud choice against the real wiring. Note the driver's documented parity caveat — a command ERROR split into a later read than its prompt is not awaited (deferred grace window). Co-Authored-By: Claude Code --- crates/ax25-node-fw/src/config.rs | 24 +++ crates/ax25-node-fw/src/main.rs | 12 ++ crates/ax25-node-fw/src/transports/mod.rs | 7 +- .../ax25-node-fw/src/transports/tait_ccdi.rs | 158 ++++++++++++++++++ 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 crates/ax25-node-fw/src/transports/tait_ccdi.rs diff --git a/crates/ax25-node-fw/src/config.rs b/crates/ax25-node-fw/src/config.rs index 5779c1f..1ec512a 100644 --- a/crates/ax25-node-fw/src/config.rs +++ b/crates/ax25-node-fw/src/config.rs @@ -27,6 +27,7 @@ pub struct NodeConfig { pub axudp: AxudpConfig, pub kiss_tcp: KissTcpConfig, pub kiss_serial: KissSerialConfig, + pub tait: TaitConfig, pub telnet: TelnetConfig, pub netrom: NetRomConfig, /// Optional `host[:port]` of an MQTT broker to publish logs/status to @@ -95,6 +96,21 @@ pub struct KissSerialConfig { pub startup_mode: Option, } +/// CCDI-controlled Tait radio on a second UART (radio integration). +#[derive(Clone)] +pub struct TaitConfig { + /// CCDI serial rate. The radio's programmed rate wins; default + /// [`ax25_node_core::radio::tait::DEFAULT_BAUD`] (28 800). Overridable at build + /// time via `TAIT_BAUD`. + pub baud: u32, + /// Optional programmed channel to select at boot (GO_TO_CHANNEL). `None` leaves + /// the radio on its current channel. From the build env `TAIT_CHANNEL`. + pub channel: Option, + /// Seconds between RSSI polls — also the cadence at which interleaved + /// carrier-sense / PTT PROGRESS edges are drained. + pub rssi_poll_secs: u64, +} + /// Telnet command console (capability 4). #[derive(Clone)] pub struct TelnetConfig { @@ -149,6 +165,14 @@ pub fn load() -> NodeConfig { baud: 57600, startup_mode: option_env!("NINOTNC_MODE").and_then(|s| s.parse::().ok()), }, + tait: TaitConfig { + baud: parse_u32( + option_env!("TAIT_BAUD"), + ax25_node_core::radio::tait::DEFAULT_BAUD, + ), + channel: option_env!("TAIT_CHANNEL").and_then(|s| s.parse::().ok()), + rssi_poll_secs: 5, + }, telnet: TelnetConfig { port: 8023 }, netrom: NetRomConfig { originate: true, diff --git a/crates/ax25-node-fw/src/main.rs b/crates/ax25-node-fw/src/main.rs index 446ffb5..0f06bb1 100644 --- a/crates/ax25-node-fw/src/main.rs +++ b/crates/ax25-node-fw/src/main.rs @@ -389,6 +389,18 @@ mod firmware { cfg.identity.alias, ))); + // --- Tait CCDI radio control on a SECOND UART: UART0 GP0(TX)/GP1(RX), + // distinct from the NinoTNC KISS link on UART1. Drives the core CCDI driver + // (RSSI / PTT / channel / carrier-sense). Always spawns (no build-env target + // for a physical UART); it self-quiets if no Tait radio answers. COMPILE- + // VALIDATED ONLY — the live exchange needs a Tait radio on GP0/GP1. --- + spawner.spawn(defmt::unwrap!(transports::tait_ccdi::task( + p.UART0, + p.PIN_0, + p.PIN_1, + cfg.tait.clone(), + ))); + // The session supervisor (shared Sessions across transports) is the next seam. let mut ticker = Ticker::every(Duration::from_secs(10)); diff --git a/crates/ax25-node-fw/src/transports/mod.rs b/crates/ax25-node-fw/src/transports/mod.rs index d28524e..6b078c6 100644 --- a/crates/ax25-node-fw/src/transports/mod.rs +++ b/crates/ax25-node-fw/src/transports/mod.rs @@ -10,8 +10,13 @@ pub mod kiss_tcp; pub mod relay; pub mod telnet; // kiss_serial (NinoTNC over UART1 GP20/21 — NinoBLE Rev5; HARDWARE-NINOBLE.md). -// Compiled + type-checked; not spawned until a NinoTNC is wired (HW-BRINGUP Gate 6). +// Spawned; the read pump + NODES origination run, but the live exchange is +// hardware-gated on a NinoTNC (HW-BRINGUP Gate 6). Compile-validated only. pub mod kiss_serial; +// tait_ccdi (Tait TM8100/TM8200 over its CCDI control channel on UART0 GP0/GP1 — +// a SECOND UART). Spawned; RSSI/PTT/channel/carrier-sense drive loop runs, but the +// live exchange is hardware-gated on a Tait radio. Compile-validated only. +pub mod tait_ccdi; use ax25_node_core::ax25::frame::CONTROL_UI; use ax25_node_core::ax25::{Address, Callsign, Frame}; diff --git a/crates/ax25-node-fw/src/transports/tait_ccdi.rs b/crates/ax25-node-fw/src/transports/tait_ccdi.rs new file mode 100644 index 0000000..c54750c --- /dev/null +++ b/crates/ax25-node-fw/src/transports/tait_ccdi.rs @@ -0,0 +1,158 @@ +#![allow(dead_code)] // spawned now; the RSSI/PTT/channel surface is only partly driven until a session/tuning layer consumes it + +//! Tait CCDI radio control — a SECOND UART transport driving the core +//! [`TaitCcdiRadio`] driver. +//! +//! Ports the firmware-wiring half of `Packet.Radio.Tait`: the CCDI codec, the +//! strict command builders (RSSI / PTT / channel / progress-enable) and the +//! transact/demux engine are all in [`ax25_node_core::radio::tait`] (host-tested) — +//! this task only supplies the *byte source* (a [`ByteStream`] over a second UART) +//! and the periodic drive loop, exactly as [`super::kiss_serial`] does for the +//! NinoTNC KISS link. +//! +//! ## What a Tait radio gives us that a bare TNC cannot +//! +//! Driven over its CCDI serial control channel, a Tait TM8100/TM8200 exposes +//! receiver RSSI (0.1 dB units → integer tenths-of-dBm here), hardware +//! carrier-sense (DCD) edges, transmitter keying and channel selection. This task: +//! +//! - enables unsolicited PROGRESS output at boot, so carrier-sense (DCD) and PTT +//! edges are reported; +//! - optionally retunes to a configured channel at boot; +//! - polls RSSI on a ticker, draining any carrier-sense / PTT / SDM PROGRESS edges +//! demuxed during each transaction (the driver maintains +//! [`TaitCcdiRadio::channel_busy`] from them). +//! +//! ## Hardware note +//! +//! **UART0 on GP0 (TX) / GP1 (RX)** — the second UART, distinct from the NinoTNC +//! KISS link on UART1 (GP20/21). The CCDI serial rate defaults to +//! `ax25_node_core::radio::tait::DEFAULT_BAUD` (28 800 8N1), but the radio's +//! programmed rate wins — set it in [`TaitConfig`]. For the split-station head-end +//! the same driver runs over a +//! TCP [`ByteStream`] instead; here it is the local second UART. +//! +//! The UART layer is real (embassy-rp 0.10 `BufferedUart`), so this module compiles +//! and is type-checked by CI. It is HARDWARE-GATED for *running*: the live exchange +//! needs a Tait radio on GP0/GP1 — not present on the bare-Pico bench rig. Everything +//! here is COMPILE-VALIDATED ONLY until that hardware is attached. + +use ax25_node_core::radio::tait::driver::{RadioEvent, TaitCcdiRadio, TaitError}; + +use embassy_rp::bind_interrupts; +use embassy_rp::peripherals::{PIN_0, PIN_1, UART0}; +use embassy_rp::uart::{BufferedInterruptHandler, BufferedUart, Config as UartConfig}; +use embassy_rp::Peri; +use embassy_time::{Duration, Ticker}; +use static_cell::StaticCell; + +use crate::config::TaitConfig; +use crate::transports::kiss_serial::UartByteStream; + +bind_interrupts!(struct Irqs { + UART0_IRQ => BufferedInterruptHandler; +}); + +#[embassy_executor::task] +pub async fn task( + uart: Peri<'static, UART0>, + tx_pin: Peri<'static, PIN_0>, + rx_pin: Peri<'static, PIN_1>, + cfg: TaitConfig, +) { + defmt::info!( + "tait-ccdi: UART0 GP0/GP1 @ {} baud (Tait CCDI control channel)", + cfg.baud + ); + + let uart = configure_uart(uart, tx_pin, rx_pin, cfg.baud); + let mut radio = TaitCcdiRadio::new(UartByteStream::new(uart)); + + // Enable unsolicited PROGRESS output — REQUIRED before carrier-sense (DCD) and + // PTT edges are reported (FUNCTION 0/4). A radio that isn't answering yields + // NoResponse; log once and carry on (the poll loop keeps retrying implicitly). + match radio.set_progress_messages(true).await { + Ok(()) => defmt::info!("tait-ccdi: PROGRESS output enabled (carrier-sense/PTT edges)"), + Err(e) => defmt::warn!( + "tait-ccdi: enable PROGRESS failed: {}", + defmt::Debug2Format(&e) + ), + } + + // Optionally retune to a programmed conventional channel at boot (GO_TO_CHANNEL). + if let Some(channel) = cfg.channel { + match radio.go_to_channel(channel, None).await { + Ok(()) => defmt::info!("tait-ccdi: tuned to channel {=u16}", channel), + Err(e) => defmt::warn!( + "tait-ccdi: go-to-channel {=u16} failed: {}", + channel, + defmt::Debug2Format(&e) + ), + } + } + + let mut ticker = Ticker::every(Duration::from_secs(cfg.rssi_poll_secs)); + loop { + ticker.next().await; + + // Poll instantaneous RSSI. Carrier-sense / PTT / SDM PROGRESS edges that + // arrive interleaved are demuxed out of the same read into the driver's + // event buffer (and update channel_busy) — drained just below. + match radio.read_rssi_tenths().await { + Ok(tenths) => { + let busy = radio.channel_busy().unwrap_or(false); + defmt::info!( + "tait-ccdi: RSSI {=i16} tenths-dBm, channel-busy={=bool}", + tenths, + busy + ); + } + // The radio said nothing this cycle (no radio attached, or genuinely + // quiet) — stay silent rather than warn every poll. + Err(TaitError::NoResponse) => {} + Err(e) => defmt::warn!("tait-ccdi: RSSI read failed: {}", defmt::Debug2Format(&e)), + } + + // Surface the unsolicited edges demuxed during the transaction above. + for ev in radio.drain_events() { + match ev { + RadioEvent::CarrierSense(busy) => { + defmt::info!("tait-ccdi: carrier-sense {=bool} (DCD)", busy) + } + RadioEvent::Transmitter(keyed) => { + defmt::info!("tait-ccdi: transmitter {=bool} (PTT)", keyed) + } + RadioEvent::SdmDeliveryReceipt(ok) => { + defmt::info!("tait-ccdi: SDM delivery receipt {=bool}", ok) + } + RadioEvent::Progress(_) => { + defmt::info!("tait-ccdi: progress {}", defmt::Debug2Format(&ev)) + } + } + } + } +} + +/// Configure UART0 as a buffered 8N1 UART at `baud` on GP0 (TX) / GP1 (RX) — the +/// Tait CCDI control channel. Static TX/RX ring buffers sized for a couple of CCDI +/// lines (a CCDI line tops out at ~272 bytes). +fn configure_uart( + uart: Peri<'static, UART0>, + tx_pin: Peri<'static, PIN_0>, + rx_pin: Peri<'static, PIN_1>, + baud: u32, +) -> BufferedUart { + let mut config = UartConfig::default(); + config.baudrate = baud; + static TX_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + static RX_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + BufferedUart::new( + uart, + tx_pin, + rx_pin, + Irqs, + TX_BUF.init([0; 256]), + RX_BUF.init([0; 256]), + config, + ) +}