diff --git a/crates/ax25-node-fw/src/config.rs b/crates/ax25-node-fw/src/config.rs index 0ca3a93..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 @@ -88,6 +89,26 @@ 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, +} + +/// 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). @@ -140,7 +161,18 @@ 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()), + }, + 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 f6822ec..0f06bb1 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 --- @@ -371,7 +373,35 @@ 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, + ))); + + // --- 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)); loop { 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..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,61 +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::Generic(InboundEvent::AckModeData { .. }) - | NinoTncInboundEvent::Generic(InboundEvent::Unknown { .. }) => { - // ACKMODE data / unrecognised — not part of the inbound AX.25 path. + + // 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!( + "kiss-serial: NODES broadcast sent ({=usize} frame(s))", + sent + ); } - }, - // 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`. } } 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)) => { 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, + ) +}