Bläddra i källkod

Initial import: zephyr-lora relay app + driver patches

Clean split of the Zephyr LoRa relay network firmware out of the
loratest bench repo. Includes the base app (frame/beacon/CBF relay/
crypto/net), board and partition overlays, and the local Zephyr
SX126x driver patches. Excludes per-node identity keys, ESPHome/
sniffer work, and design docs from the source repo.
ZiB 1 dag sedan
incheckning
3e84da4a48
69 ändrade filer med 6980 tillägg och 0 borttagningar
  1. 2 0
      .gitignore
  2. 70 0
      CMakeLists.txt
  3. 35 0
      Kconfig
  4. 55 0
      apply-zephyr-patches.sh
  5. 24 0
      base-station.overlay
  6. 84 0
      boards/esp32s3_devkitc_esp32s3_procpu.overlay
  7. 41 0
      flash16m.overlay
  8. 1 0
      mcuboot-flash16m.overlay
  9. 71 0
      overlay-base.conf
  10. 41 0
      prj.conf
  11. 265 0
      src/beacon/beacon_ctx.c
  12. 144 0
      src/beacon/beacon_ctx.h
  13. 189 0
      src/beacon/beacon_rx.c
  14. 43 0
      src/beacon/beacon_rx.h
  15. 30 0
      src/beacon/beacon_work.c
  16. 23 0
      src/beacon/beacon_work.h
  17. 18 0
      src/crypto/base_ident_pubkey.c
  18. 17 0
      src/crypto/base_ident_pubkey.h
  19. 7 0
      src/crypto/group_key.c
  20. 16 0
      src/crypto/group_key.h
  21. 87 0
      src/crypto/group_mac.c
  22. 49 0
      src/crypto/group_mac.h
  23. 132 0
      src/crypto/ident.c
  24. 83 0
      src/crypto/ident.h
  25. 51 0
      src/crypto/ident_privkey.c
  26. 31 0
      src/crypto/ident_privkey.h
  27. 20 0
      src/crypto/ident_stub.c
  28. 24 0
      src/crypto/ident_stub.h
  29. 140 0
      src/crypto/pubkey_table.c
  30. 121 0
      src/crypto/pubkey_table.h
  31. 111 0
      src/frame/beacon_frame.c
  32. 132 0
      src/frame/beacon_frame.h
  33. 120 0
      src/frame/frame.c
  34. 116 0
      src/frame/frame.h
  35. 63 0
      src/frame/msg_frame.c
  36. 119 0
      src/frame/msg_frame.h
  37. 17 0
      src/frame/pkt_log.c
  38. 29 0
      src/frame/pkt_log.h
  39. 55 0
      src/frame/rx_ctx.h
  40. 65 0
      src/frame/rx_dedup.c
  41. 34 0
      src/frame/rx_dedup.h
  42. 30 0
      src/log_ctx.h
  43. 43 0
      src/log_levels.h
  44. 571 0
      src/main.c
  45. 230 0
      src/net/net_base.c
  46. 33 0
      src/net/net_base.h
  47. 221 0
      src/net/peer_stats.c
  48. 179 0
      src/net/peer_stats.h
  49. 169 0
      src/net/time_ctx.c
  50. 84 0
      src/net/time_ctx.h
  51. 13 0
      src/psram_bss.h
  52. 178 0
      src/relay/cad_rx.c
  53. 43 0
      src/relay/cad_rx.h
  54. 290 0
      src/relay/cbf.c
  55. 27 0
      src/relay/cbf.h
  56. 27 0
      src/relay/relay_work.c
  57. 22 0
      src/relay/relay_work.h
  58. 36 0
      src/relay/topology.h
  59. 356 0
      src/relay/tx_queue.c
  60. 103 0
      src/relay/tx_queue.h
  61. 81 0
      src/rx/discovery_rx.c
  62. 42 0
      src/rx/discovery_rx.h
  63. 206 0
      src/rx/payload_rx.c
  64. 46 0
      src/rx/payload_rx.h
  65. 27 0
      src/rx/payload_work.c
  66. 22 0
      src/rx/payload_work.h
  67. 221 0
      zephyr-patches/0001-drivers-lora-sx126x-add-one-shot-IRQ-timestamp-peek.patch
  68. 467 0
      zephyr-patches/0002-drivers-lora-native-sx126x-implement-Channel-Activit.patch
  69. 438 0
      zephyr-patches/0003-drivers-lora-native-sx126x-implement-driver-level-LB.patch

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+build*/
+.cache/

+ 70 - 0
CMakeLists.txt

@@ -0,0 +1,70 @@
+cmake_minimum_required(VERSION 3.20.0)
+find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
+project(relay_zephyr)
+
+# Logged at boot (see main.c) so a serial capture says which app commit -
+# dirty tree or not - is actually running, independent of the Zephyr
+# kernel's own "Booting Zephyr OS build ..." line, which only identifies
+# the vendored zephyr tree, not this app's source.
+execute_process(
+	COMMAND git -C ${CMAKE_CURRENT_SOURCE_DIR} describe --always --dirty --long
+	OUTPUT_VARIABLE APP_BUILD_ID
+	OUTPUT_STRIP_TRAILING_WHITESPACE
+	ERROR_QUIET
+)
+if(NOT APP_BUILD_ID)
+	set(APP_BUILD_ID "unknown")
+endif()
+
+# CONFIG_DISABLE_MAC_VERIFY/CONFIG_DISABLE_PUBKEY_VERIFY (see Kconfig)
+# short-circuit the two real auth checks to always pass - bench/test
+# builds only, never meant to reach a fielded unit. CONFIG_* is already
+# available here: find_package(Zephyr ...) above has already run
+# Kconfig by this point. Flagging it in the build tag means a UART/
+# syslog capture alone proves whether auth was live, without having to
+# also know or trust the build's Kconfig fragment.
+if(CONFIG_DISABLE_MAC_VERIFY OR CONFIG_DISABLE_PUBKEY_VERIFY)
+	set(APP_BUILD_ID "${APP_BUILD_ID}-NOAUTH")
+endif()
+
+target_compile_definitions(app PRIVATE APP_BUILD_ID="${APP_BUILD_ID}")
+
+# Headers are included by their path relative to src (e.g.
+# "frame/frame.h"), so only src itself needs to be on the include path -
+# no per-module directory, and no risk of a same-named header in two
+# modules shadowing each other.
+target_include_directories(app PRIVATE
+	src
+)
+
+target_sources(app PRIVATE
+	src/main.c
+	src/beacon/beacon_ctx.c
+	src/beacon/beacon_rx.c
+	src/beacon/beacon_work.c
+	src/frame/beacon_frame.c
+	src/frame/frame.c
+	src/frame/msg_frame.c
+	src/frame/pkt_log.c
+	src/frame/rx_dedup.c
+	src/relay/cad_rx.c
+	src/relay/cbf.c
+	src/relay/relay_work.c
+	src/relay/tx_queue.c
+	src/rx/discovery_rx.c
+	src/rx/payload_rx.c
+	src/rx/payload_work.c
+	src/net/peer_stats.c
+	src/net/time_ctx.c
+	src/crypto/base_ident_pubkey.c
+	src/crypto/group_key.c
+	src/crypto/group_mac.c
+	src/crypto/ident.c
+	src/crypto/ident_privkey.c
+	src/crypto/ident_stub.c
+	src/crypto/pubkey_table.c
+)
+
+# Base-station-only networking (WiFi connect) - every other node shares
+# the frame/CBF/topology code above unconditionally and skips this.
+target_sources_ifdef(CONFIG_APP_BASE_STATION app PRIVATE src/net/net_base.c)

+ 35 - 0
Kconfig

@@ -0,0 +1,35 @@
+menu "relay-zephyr app options"
+
+config APP_BASE_STATION
+	bool "Build as the network base station (WiFi + syslog + telnet)"
+	help
+	  Selects the base-station variant of this app: adds WiFi, a syslog
+	  log backend, and a telnet shell on top of the same frame/CBF/
+	  topology code every node shares. Off by default - the sign/relay
+	  nodes stay UART-supervised with no networking at all.
+
+config DISABLE_MAC_VERIFY
+	bool "TEST ONLY: short-circuit group_mac_verify() to always pass"
+	help
+	  group_mac_verify() returns true unconditionally, without even
+	  computing the real HMAC. For bench/range testing when the group
+	  key hasn't been provisioned the same on every unit, or when
+	  isolating a bug from the MAC check itself. See APP_BUILD_ID's
+	  "-NOAUTH" suffix (CMakeLists.txt) - sign/relay logs only ever
+	  prove what build tag was running, so that suffix is the actual
+	  guardrail against this reaching a fielded unit unnoticed. Never
+	  enable for anything but a bench/test build.
+
+config DISABLE_PUBKEY_VERIFY
+	bool "TEST ONLY: short-circuit ident_verify() to always pass"
+	help
+	  ident_verify() returns true unconditionally, without even
+	  importing the key or checking the signature. For bench/range
+	  testing before a real pubkey table/base identity key is
+	  provisioned on every unit. See APP_BUILD_ID's "-NOAUTH" suffix
+	  (CMakeLists.txt). Never enable for anything but a bench/test
+	  build.
+
+endmenu
+
+source "Kconfig.zephyr"

+ 55 - 0
apply-zephyr-patches.sh

@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# Applies this app's local patches on top of the shared Zephyr checkout, so
+# that checkout stays a clean, reusable resource for other projects while
+# still carrying the changes this app needs. Run once after a fresh
+# `west update`, or after pulling upstream changes into the shared tree.
+#
+# Patches are tracked in zephyr-patches/ as plain `git format-patch`
+# output; applied with `git am` so they land as real commits (reviewable,
+# revertable) rather than a silent working-tree diff.
+set -euo pipefail
+
+ZEPHYR_BASE="${ZEPHYR_BASE:-./zephyrproject/zephyr}"
+PATCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/zephyr-patches"
+
+if [ ! -d "$ZEPHYR_BASE/.git" ]; then
+	echo "error: $ZEPHYR_BASE is not a git checkout" >&2
+	exit 1
+fi
+
+cd "$ZEPHYR_BASE"
+
+if [ -n "$(git status --porcelain)" ]; then
+	echo "error: $ZEPHYR_BASE has uncommitted changes - commit, stash, or" >&2
+	echo "       reset it before applying patches, so it's clear which" >&2
+	echo "       commits came from these patches vs. local hacking." >&2
+	exit 1
+fi
+
+for patch in "$PATCH_DIR"/*.patch; do
+	# git mailinfo is the same header parser `git am` itself uses, so it
+	# correctly strips "[PATCH]"/"[PATCH 1/2]" prefixes and un-wraps a
+	# subject line that spans multiple header lines - a plain sed/grep
+	# on "Subject:" got this wrong for a >1-patch series and silently
+	# skipped every patch without applying them (grep -qF against an
+	# empty extracted subject matches any line).
+	msgfile=$(mktemp)
+	bodyfile=$(mktemp)
+	subject=$(git mailinfo "$msgfile" "$bodyfile" < "$patch" | sed -n 's/^Subject: //p')
+	rm -f "$msgfile" "$bodyfile"
+
+	if [ -z "$subject" ]; then
+		echo "error: could not extract a Subject from $(basename "$patch")" >&2
+		exit 1
+	fi
+
+	history=$(git log --oneline)
+	if grep -qF "$subject" <<< "$history"; then
+		echo "skip (already applied): $subject"
+		continue
+	fi
+	echo "applying: $(basename "$patch")"
+	git am "$patch"
+done
+
+echo "done - $ZEPHYR_BASE now carries $(ls "$PATCH_DIR"/*.patch | wc -l | tr -d ' ') local patch(es)."

+ 24 - 0
base-station.overlay

@@ -0,0 +1,24 @@
+#include "flash16m.overlay"
+
+/delete-node/ &littlefs_partition;
+
+&flash0 {
+	partitions {
+		littlefs_partition: partition@ea1000 {
+			compatible = "zephyr,mapped-partition";
+			label = "littlefs";
+			reg = <0xea1000 DT_SIZE_K(1024)>;
+		};
+	};
+};
+
+&usb_serial {
+	status = "okay";
+};
+
+/ {
+	chosen {
+		zephyr,console = &usb_serial;
+		zephyr,shell-uart = &usb_serial;
+	};
+};

+ 84 - 0
boards/esp32s3_devkitc_esp32s3_procpu.overlay

@@ -0,0 +1,84 @@
+/*
+ * Wire spi2 from to the Wio-S3 Sx1262 pins:
+ *   SPI  CLK=4  MOSI=6  MISO=5
+ *   CS=21  RST=7  BUSY=8  DIO1=9
+ */
+
+#include <zephyr/dt-bindings/lora/sx126x.h>
+
+&pinctrl {
+	relay_spim2_default: relay_spim2_default {
+		group1 {
+			pinmux = <SPIM2_MISO_GPIO5>,
+				 <SPIM2_SCLK_GPIO4>;
+		};
+
+		group2 {
+			pinmux = <SPIM2_MOSI_GPIO6>;
+			output-low;
+		};
+	};
+};
+
+&spi2 {
+	status = "okay";
+	pinctrl-0 = <&relay_spim2_default>;
+	pinctrl-names = "default";
+
+	cs-gpios = <&gpio0 21 GPIO_ACTIVE_LOW>;
+
+	lora0: modem@0 {
+		compatible = "semtech,sx1262";
+		reg = <0>;
+		reset-gpios = <&gpio0 7 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW)>;
+		busy-gpios = <&gpio0 8 GPIO_ACTIVE_HIGH>;
+		dio1-gpios = <&gpio0 9 GPIO_ACTIVE_HIGH>;
+		/* DIO2 drives the board's RF switch, same as the ESPHome rf_switch:true. */
+		dio2-tx-enable;
+		dio3-tcxo-voltage = <SX126X_DIO3_TCXO_1V8>;
+		tcxo-power-startup-delay-ms = <5>;
+		spi-max-frequency = <4000000>;
+	};
+};
+
+/ {
+	aliases {
+		lora0 = &lora0;
+	};
+};
+
+&flash0 {
+	reg = <0x0 DT_SIZE_M(16)>;
+	ranges = <0x0 0x0 DT_SIZE_M(16)>;
+};
+
+&psram0 {
+	size = <DT_SIZE_M(8)>;
+};
+
+&flash0 {
+	partitions {
+		littlefs_partition: partition@400000 {
+			compatible = "zephyr,mapped-partition";
+			label = "littlefs";
+			reg = <0x400000 DT_SIZE_K(1024)>;
+		};
+	};
+};
+
+/*
+ * Mounts littlefs_partition at /lfs
+ */
+/ {
+	lfs: lfs {
+		compatible = "zephyr,fstab,littlefs";
+		mount-point = "/lfs";
+		partition = <&littlefs_partition>;
+		automount;
+		read-size = <16>;
+		prog-size = <16>;
+		cache-size = <64>;
+		lookahead-size = <32>;
+		block-cycles = <512>;
+	};
+};

+ 41 - 0
flash16m.overlay

@@ -0,0 +1,41 @@
+/delete-node/ &boot_partition;
+/delete-node/ &sys_partition;
+/delete-node/ &slot0_partition;
+/delete-node/ &slot1_partition;
+/delete-node/ &slot0_appcpu_partition;
+/delete-node/ &slot1_appcpu_partition;
+/delete-node/ &slot0_lpcore_partition;
+/delete-node/ &slot1_lpcore_partition;
+/delete-node/ &storage_partition;
+/delete-node/ &coredump_partition;
+
+#include <espressif/partitions_0x0_default_16M.dtsi>
+
+&slot0_partition {
+	reg = <0x20000 DT_SIZE_K(7424)>;
+};
+
+&slot1_partition {
+	/* slot0 (7424K) + 1 sector (4096 B), per boot_slots_compatible()'s
+	 * "secondary has one more than primary" design. */
+	reg = <0x760000 DT_SIZE_K(7428)>;
+};
+
+&slot0_lpcore_partition {
+	reg = <0xfa1000 DT_SIZE_K(32)>;
+};
+
+&slot1_lpcore_partition {
+	reg = <0xfa9000 DT_SIZE_K(32)>;
+};
+
+&storage_partition {
+	/* Size unchanged (192K, 8x what CONFIG_SETTINGS_NVS_SECTOR_COUNT
+	 * actually uses) - just shifted to follow slot1's new end. */
+	reg = <0xfb1000 DT_SIZE_K(192)>;
+};
+
+&flash0 {
+	reg = <0x0 DT_SIZE_M(16)>;
+	ranges = <0x0 0x0 DT_SIZE_M(16)>;
+};

+ 1 - 0
mcuboot-flash16m.overlay

@@ -0,0 +1 @@
+#include "flash16m.overlay"

+ 71 - 0
overlay-base.conf

@@ -0,0 +1,71 @@
+CONFIG_LOG_MODE_IMMEDIATE=n
+CONFIG_LOG_MODE_DEFERRED=y
+
+# Force log filtering off; we have compile-time debug flags
+CONFIG_LOG_RUNTIME_FILTERING=n
+
+CONFIG_APP_BASE_STATION=y
+
+CONFIG_NETWORKING=y
+CONFIG_NET_IPV4=y
+CONFIG_NET_TCP=y
+CONFIG_NET_UDP=y
+CONFIG_NET_SOCKETS=y
+CONFIG_NET_CONNECTION_MANAGER=y
+
+# DHCPv4 client
+CONFIG_NET_DHCPV4=y
+
+# NTP time sync
+CONFIG_SNTP=y
+CONFIG_SNTP_UNCERTAINTY=y
+
+# WiFi
+CONFIG_WIFI=y
+CONFIG_WIFI_ESP32=y
+
+CONFIG_SHELL=y
+CONFIG_NET_L2_WIFI_SHELL=y
+# ESP32 needs more stack space than nordic
+CONFIG_SHELL_STACK_SIZE=6144
+# `net iface`/`net dhcpv4` etc, for checking the DHCP lease over the console.
+CONFIG_NET_SHELL=y
+
+# Credentials live in flash (Zephyr settings subsystem), provisioned once
+# over the existing UART console via `wifi cred add` + `wifi cred
+# auto_connect`, not baked into the image - every base station flashes
+# the same binary. net_base.c just replays NET_REQUEST_WIFI_CONNECT_STORED
+# on every boot after the first.
+CONFIG_WIFI_CREDENTIALS=y
+CONFIG_WIFI_CREDENTIALS_BACKEND_SETTINGS=y
+CONFIG_WIFI_CREDENTIALS_SHELL=y
+CONFIG_WIFI_CREDENTIALS_CONNECT_STORED=y
+CONFIG_SETTINGS=y
+CONFIG_SETTINGS_NVS=y
+CONFIG_NVS=y
+
+# Syslog (RFC 5424/UDP) - device-network collector.
+CONFIG_LOG_BACKEND_NET=y
+CONFIG_LOG_BACKEND_NET_SERVER="172.22.1.10:514"
+CONFIG_LOG_BACKEND_NET_AUTOSTART=y
+# ESP32 needs more stack space than nordic
+CONFIG_LOG_PROCESS_THREAD_STACK_SIZE=6144
+
+# Telnet shell
+CONFIG_SHELL_BACKEND_TELNET=y
+
+# OTA via mcumgr/SMP over UDP
+CONFIG_BOOTLOADER_MCUBOOT=y
+CONFIG_STREAM_FLASH=y
+CONFIG_IMG_MANAGER=y
+CONFIG_MCUBOOT_IMG_MANAGER=y
+CONFIG_ZCBOR=y
+CONFIG_MCUMGR=y
+CONFIG_MCUMGR_GRP_IMG=y
+CONFIG_MCUMGR_GRP_OS=y
+CONFIG_MCUMGR_TRANSPORT_UDP=y
+CONFIG_MCUMGR_TRANSPORT_UDP_IPV4=y
+
+# File management (mcumgr fs upload/download) over the same UDP/SMP
+# transport as the OTA path above
+# CONFIG_MCUMGR_GRP_FS=y

+ 41 - 0
prj.conf

@@ -0,0 +1,41 @@
+
+CONFIG_LOG=y
+CONFIG_LOG_MODE_IMMEDIATE=y
+
+CONFIG_ESP_SPIRAM=y
+CONFIG_SPIRAM_MODE_OCT=y
+
+CONFIG_LOG_TIMESTAMP_USE_REALTIME=y
+
+CONFIG_LORA=y
+CONFIG_LORA_MODULE_BACKEND_NATIVE=y
+
+CONFIG_GPIO=y
+CONFIG_SPI=y
+
+CONFIG_MAIN_STACK_SIZE=4096
+CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096
+
+CONFIG_PSA_CRYPTO=y
+CONFIG_MBEDTLS_PSA_STATIC_KEY_SLOTS=y
+CONFIG_PSA_WANT_ALG_SHA_256=y
+CONFIG_PSA_WANT_ALG_HMAC=y
+CONFIG_PSA_WANT_KEY_TYPE_HMAC=y
+
+CONFIG_PSA_WANT_ALG_ECDSA=y
+CONFIG_PSA_WANT_ECC_SECP_R1_256=y
+CONFIG_PSA_WANT_KEY_TYPE_ECC_PUBLIC_KEY=y
+CONFIG_PSA_WANT_KEY_TYPE_ECC_KEY_PAIR_IMPORT=y
+
+CONFIG_FLASH=y
+CONFIG_FLASH_MAP=y
+CONFIG_FILE_SYSTEM=y
+CONFIG_FILE_SYSTEM_LITTLEFS=y
+
+CONFIG_STACK_SENTINEL=y
+CONFIG_THREAD_NAME=y
+CONFIG_THREAD_MONITOR=y
+CONFIG_ASSERT=y
+
+CONFIG_FS_LOG_LEVEL_INF=y
+CONFIG_FLASH_LOG_LEVEL_WRN=y

+ 265 - 0
src/beacon/beacon_ctx.c

@@ -0,0 +1,265 @@
+#include "beacon/beacon_ctx.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+#include "psram_bss.h"
+
+#include <string.h>
+
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/atomic.h>
+#include <zephyr/sys/util.h>
+
+LOG_MODULE_REGISTER(beacon_ctx, LOG_LEVEL_BEACON_CTX);
+
+/*
+ * Pool of refcounted table snapshots - see struct beacon_ctx_ref's
+ * header comment for why this replaced the old fixed 2-slot ping-pong
+ * buffer. "current" always holds one ref of its own on whichever slot
+ * is active; beacon_ctx_acquire() takes an extra one for the caller,
+ * released via beacon_ctx_release() once that caller is done with it.
+ * A slot is free (reusable by find_free_slot()) only once its refcount
+ * drops to zero - which, for the previously-current slot, only happens
+ * after both beacon_ctx_commit() has moved "current" on to a new slot
+ * *and* every packet that acquired a ref while it was still current has
+ * released it.
+ *
+ * Sized for "current" plus a handful of slow, concurrently in-flight
+ * packets (today: one single-slot busy-gated RX dispatch handler per
+ * PTYPE, plus whatever's still queued in tx_queue) each holding their
+ * own ref across a reload. Not sized to guarantee a free slot under
+ * worst-case contention - beacon_ctx_commit() just skips the reload
+ * (same fallback as a failed pubkey_table_load()) if the pool is
+ * exhausted, logging rather than blocking or evicting a still-referenced
+ * slot.
+ */
+#define BEACON_CTX_POOL_SIZE 4
+
+struct beacon_ctx_snapshot {
+	/* Must stay the first member: beacon_ctx_release() recovers the
+	 * enclosing snapshot from a bare `const struct pubkey_table *` via
+	 * CONTAINER_OF.
+	 */
+	struct pubkey_table table;
+	uint8_t table_buf[PUBKEY_TABLE_MAX_LOAD_LEN];
+	atomic_t refcount; /* 0 = free */
+};
+
+/* The bulk of this app's static data (BEACON_CTX_POOL_SIZE slots *
+ * PUBKEY_TABLE_MAX_LOAD_LEN each, now sized for the full node_id
+ * address space - see pubkey_table.h) - PSRAM-backed, not internal
+ * SRAM, per PSRAM_BSS's own header comment. current_info/current_snap/
+ * lock below stay in normal (internal SRAM) BSS: small, and touched on
+ * every beacon_ctx_acquire()/commit, not worth PSRAM's extra latency.
+ */
+static PSRAM_BSS struct beacon_ctx_snapshot pool[BEACON_CTX_POOL_SIZE];
+static struct beacon_ctx_info current_info;
+static struct beacon_ctx_snapshot *current_snap; /* NULL = nothing committed yet */
+static struct k_spinlock lock;
+
+static struct beacon_ctx_snapshot *find_free_slot(void)
+{
+	for (int i = 0; i < BEACON_CTX_POOL_SIZE; i++) {
+		if (atomic_get(&pool[i].refcount) == 0) {
+			return &pool[i];
+		}
+	}
+	return NULL;
+}
+
+void beacon_ctx_init(void)
+{
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	memset(&current_info, 0, sizeof(current_info));
+	memset(pool, 0, sizeof(pool));
+	current_snap = NULL;
+
+	k_spin_unlock(&lock, key);
+
+	LOG_CTX_DBG("init", "pool cleared");
+}
+
+void beacon_ctx_bootstrap_table(uint32_t table_id)
+{
+	struct beacon_ctx_snapshot *slot = find_free_slot();
+
+	LOG_CTX_DBG("bootstrap_table", "table_id=0x%03x", table_id);
+
+	/* Can't fail at boot (see beacon_ctx_init()'s all-zero pool), but
+	 * fail closed rather than dereference NULL if that ever changes.
+	 */
+	if (!slot) {
+		LOG_ERR("beacon_ctx_bootstrap_table(0x%03x): no free slot (unexpected at boot)",
+			table_id);
+		return;
+	}
+
+	int ret = pubkey_table_load(table_id, slot->table_buf, sizeof(slot->table_buf),
+				     &slot->table);
+
+	if (ret < 0) {
+		LOG_WRN("beacon_ctx_bootstrap_table(0x%03x) failed: %d - no table committed",
+			table_id, ret);
+		return;
+	}
+
+	atomic_set(&slot->refcount, 1); /* "current"'s own ref */
+
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	current_snap = slot;
+
+	k_spin_unlock(&lock, key);
+
+	LOG_INF("beacon_ctx_bootstrap_table(0x%03x) ok: entry_count=%u", table_id,
+		slot->table.entry_count);
+}
+
+void beacon_ctx_commit(const struct beacon_frame *frame)
+{
+	struct beacon_ctx_info info = {
+		.timestamp_ms = frame->timestamp_ms,
+		.interval_ms = frame->interval_ms,
+		.expiration_ms = frame->timestamp_ms + frame->interval_ms,
+		.kv_count = frame->kv_count,
+	};
+
+	memcpy(info.kv_keys, frame->kv_keys, sizeof(info.kv_keys[0]) * frame->kv_count);
+	memcpy(info.kv_values, frame->kv_values, sizeof(info.kv_values[0]) * frame->kv_count);
+
+	/* Does this beacon advertise a table_id we don't already have? */
+	uint32_t advertised = PUBKEY_TABLE_ID_NONE;
+
+	for (size_t i = 0; i < frame->kv_count; i++) {
+		if (frame->kv_keys[i] == BEACON_KEY_TABLE_CURRENT) {
+			advertised = frame->kv_values[i];
+			break;
+		}
+	}
+
+	/* Single-writer assumption (see header comment): reading
+	 * current_snap outside the lock here is safe only because
+	 * beacon_ctx_commit() is never called concurrently with itself or
+	 * with beacon_ctx_bootstrap_table() - both run serialized on
+	 * beacon_work_q/boot. Revisit if a second writer ever shows up.
+	 */
+	struct beacon_ctx_snapshot *have_snap = current_snap;
+	uint32_t have = have_snap ? have_snap->table.table_id : PUBKEY_TABLE_ID_NONE;
+	bool wants_reload = advertised != PUBKEY_TABLE_ID_NONE && advertised != have;
+
+	LOG_CTX_DBG("commit", "advertised=0x%03x have=0x%03x wants_reload=%d", advertised, have,
+		    wants_reload);
+
+	struct beacon_ctx_snapshot *new_slot = NULL;
+	bool reload_ok = false;
+
+	if (wants_reload) {
+		new_slot = find_free_slot();
+
+		if (!new_slot) {
+			LOG_WRN("Beacon advertises table 0x%03x, no free snapshot slot (all %d "
+				"in use) - keeping table 0x%03x", advertised,
+				BEACON_CTX_POOL_SIZE, have);
+		} else {
+			int ret = pubkey_table_load(advertised, new_slot->table_buf,
+						     sizeof(new_slot->table_buf), &new_slot->table);
+
+			LOG_CTX_DBG("commit/reload", "table_id=0x%03x ret=%d", advertised, ret);
+
+			if (ret == 0) {
+				reload_ok = true;
+			} else {
+				LOG_WRN("Beacon advertises table 0x%03x, reload failed: %d - "
+					"keeping table 0x%03x", advertised, ret, have);
+			}
+		}
+	}
+
+	if (reload_ok) {
+		atomic_set(&new_slot->refcount, 1); /* this commit's own "current" ref */
+	}
+
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	current_info = info;
+	struct beacon_ctx_snapshot *prev = current_snap;
+
+	if (reload_ok) {
+		current_snap = new_slot;
+	}
+
+	k_spin_unlock(&lock, key);
+
+	if (reload_ok && prev) {
+		/* Drop "current"'s own ref on the old snapshot - it stays
+		 * alive only if some packet still references it, and is
+		 * only reusable (find_free_slot()) once that drops too.
+		 */
+		atomic_dec(&prev->refcount);
+	}
+
+	if (reload_ok) {
+		LOG_INF("Beacon-driven table reload: now on 0x%03x (was 0x%03x)", advertised,
+			have);
+	}
+}
+
+struct beacon_ctx_ref beacon_ctx_acquire(void)
+{
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	struct beacon_ctx_ref ref = {
+		.info = current_info,
+		.table = current_snap ? &current_snap->table : NULL,
+	};
+
+	if (current_snap) {
+		atomic_inc(&current_snap->refcount);
+	}
+
+	k_spin_unlock(&lock, key);
+
+	LOG_CTX_DBG("acquire", "table_set=%d table_id=0x%03x", ref.table != NULL,
+		    ref.table ? ref.table->table_id : (uint32_t)PUBKEY_TABLE_ID_NONE);
+
+	return ref;
+}
+
+void beacon_ctx_release(struct beacon_ctx_ref ref)
+{
+	if (!ref.table) {
+		return;
+	}
+
+	struct beacon_ctx_snapshot *snap =
+		CONTAINER_OF((struct pubkey_table *)ref.table, struct beacon_ctx_snapshot, table);
+
+	atomic_dec(&snap->refcount);
+}
+
+bool beacon_ctx_ref_lookup_pubkey(struct beacon_ctx_ref ref, uint8_t node_id,
+				   uint8_t pubkey_out[PUBKEY_LEN])
+{
+	if (!ref.table) {
+		LOG_CTX_DBG("ref_lookup_pubkey", "node_id=%u no table on this ref", node_id);
+		return false;
+	}
+
+	bool ok = pubkey_table_lookup(ref.table, node_id, pubkey_out);
+
+	LOG_CTX_DBG("ref_lookup_pubkey", "node_id=%u ok=%d", node_id, ok);
+
+	return ok;
+}
+
+uint32_t beacon_ctx_table_id(void)
+{
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	uint32_t id = current_snap ? current_snap->table.table_id : PUBKEY_TABLE_ID_NONE;
+
+	k_spin_unlock(&lock, key);
+
+	return id;
+}

+ 144 - 0
src/beacon/beacon_ctx.h

@@ -0,0 +1,144 @@
+/*
+ * The single "most current beacon info + most current pubkey table" that
+ * other components read from - decoupled from beacon_rx.c's own decode/
+ * verify work so a reader never has to synchronize with beacon_work_q
+ * itself, just take a lock for the instant it takes to acquire a
+ * reference (see struct beacon_ctx_ref below).
+ *
+ * The beacon info and the pubkey table are committed together, under one
+ * lock, on purpose: a verified Beacon's KV section is central telling
+ * everyone which pubkey table is current (BEACON_KEY_TABLE_CURRENT, see
+ * beacon_frame.h), so "which beacon is current" and "which table is
+ * current" are really one piece of state, not two.
+ *
+ * What this does NOT solve (deliberately, for now): two beacons
+ * decoding/verifying back to back, overlapping with each other. There's
+ * exactly one writer today (beacon_rx_work_handler, serialized by its
+ * own single-slot busy flag - see beacon_rx.c), so that race doesn't
+ * exist yet in practice; this module only arbitrates concurrent
+ * *readers* against the one writer, not concurrent *writers*. Revisit if
+ * a second writer ever shows up.
+ *
+ * Written only from beacon_work_q (see beacon_rx.c), after a frame has
+ * decoded and sig-verified cleanly, plus once at boot for the initial
+ * table bootstrap (beacon_ctx_bootstrap_table(), before any beacon has
+ * ever been seen). A new valid beacon replaces the previous beacon info
+ * wholesale; the pubkey table only changes when the beacon's own
+ * advertisement says to, and only if that reload actually succeeds.
+ */
+#ifndef BEACON_CTX_H_
+#define BEACON_CTX_H_
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+#include "frame/beacon_frame.h"
+#include "crypto/pubkey_table.h"
+
+/* timestamp_ms is 0 before the first valid beacon is ever seen - the
+ * flag consumers check before trusting anything else in the struct,
+ * same sense "0" already has in beacon_frame.h (the epoch itself, not a
+ * real wall-clock time).
+ */
+struct beacon_ctx_info {
+	uint64_t timestamp_ms;
+	uint32_t interval_ms;
+	uint64_t expiration_ms; /* timestamp_ms + interval_ms; only meaningful
+				  * when timestamp_ms != 0.
+				  */
+	uint16_t kv_keys[BEACON_MAX_KV_PAIRS];
+	uint32_t kv_values[BEACON_MAX_KV_PAIRS];
+	size_t kv_count;
+};
+
+/*
+ * A frozen handle on one committed beacon: info is a plain copy, taken
+ * the instant beacon_ctx_acquire() ran (cheap - a few dozen bytes, no
+ * pointers - and never changes afterward, so no lock is needed to read
+ * it back out of a ref). table points into a refcounted pubkey-table
+ * snapshot that beacon_ctx keeps alive for as long as this ref is held,
+ * however many newer beacons commit behind it in the meantime - see
+ * beacon_ctx_acquire(). table is NULL if nothing has ever been
+ * committed (bootstrap never ran, or failed) - check that before
+ * trusting info too (info.timestamp_ms == 0 the same way, kept for the
+ * same "nothing yet" reason both already had).
+ *
+ * This replaces the old fixed 2-slot ping-pong table buffer. That
+ * scheme assumed a reload only ever needs to outlive the *previous*
+ * commit - true for a reader that copies out and is done instantly, but
+ * not for a reference held across real deferred work (a queued TX item,
+ * a worker on another queue): a second beacon landing while that
+ * reference is still outstanding would reuse the "other" slot out from
+ * under it. Refcounting (see beacon_ctx.c's pool) fixes this in
+ * general, at the cost of needing more than 2 slots and a linear scan
+ * to find a free one.
+ *
+ * Meant to be acquired once per RX/TX packet context (main.c's rx_work,
+ * tx_queue's tx_item) and threaded through to every worker that touches
+ * that one packet, so they all see the same snapshot instead of each
+ * independently calling into beacon_ctx at its own later point in time
+ * and possibly observing different state. Passed/stored by value - it's
+ * two small fields, not a resource in itself; beacon_ctx_release() is
+ * what returns the thing it points at. Every beacon_ctx_acquire() must
+ * be matched by exactly one beacon_ctx_release(), called by whichever
+ * worker is the last to touch the packet this ref was attached to (i.e.
+ * once that packet is dequeued/finished, not before).
+ */
+struct beacon_ctx_ref {
+	struct beacon_ctx_info info;
+	const struct pubkey_table *table;
+};
+
+/* Zero-initializes the held info and clears the pool (no table
+ * committed). Call once at boot, before beacon_ctx_bootstrap_table().
+ */
+void beacon_ctx_init(void);
+
+/*
+ * One-time boot bootstrap: loads table_id via pubkey_table_load() and,
+ * if that succeeds, commits it as the active table (no beacon info
+ * change - there is no beacon yet). This is the only table a node will
+ * ever trust until the first beacon it verifies against it advertises a
+ * different one (see beacon_ctx_commit()). Logs and leaves no table
+ * committed on failure - every later beacon_ctx_ref_lookup_pubkey() then
+ * fails closed (see pubkey_table.h/ident.h).
+ */
+void beacon_ctx_bootstrap_table(uint32_t table_id);
+
+/*
+ * Commits a newly decoded + sig-verified beacon frame. If frame's
+ * BEACON_KEY_TABLE_CURRENT KV differs from the table already committed,
+ * attempts to load that new table into a free pool slot first
+ * (pubkey_table_load(), blocking fs I/O - done here, synchronously,
+ * before anything is swapped in). A failed reload, or no free slot (see
+ * beacon_ctx.h's pool-sizing note in beacon_ctx.c), still commits the
+ * beacon info; the old table (if any) stays active - a stale table is
+ * still mostly trustworthy, see pubkey_table.h - so one bad/missing
+ * table file (or one slow-to-drain packet pinning the pool) never drops
+ * a node's Beacon sync entirely.
+ */
+void beacon_ctx_commit(const struct beacon_frame *frame);
+
+/* See struct beacon_ctx_ref. */
+struct beacon_ctx_ref beacon_ctx_acquire(void);
+void beacon_ctx_release(struct beacon_ctx_ref ref);
+
+/* Pubkey lookup against ref's table. Returns false (pubkey_out
+ * untouched) if ref.table is NULL (nothing committed when this ref was
+ * acquired) or node_id isn't in it.
+ */
+bool beacon_ctx_ref_lookup_pubkey(struct beacon_ctx_ref ref, uint8_t node_id,
+				   uint8_t pubkey_out[PUBKEY_LEN]);
+
+/*
+ * Instantaneous peek at the currently-committed table_id, or
+ * PUBKEY_TABLE_ID_NONE if nothing is committed yet. Doesn't acquire or
+ * extend any snapshot's lifetime - for a one-shot read (e.g. what to
+ * advertise in the next outgoing Beacon), not for attaching to a
+ * packet; use beacon_ctx_acquire() for anything that needs to stay
+ * consistent across more than one instant.
+ */
+uint32_t beacon_ctx_table_id(void);
+
+#endif /* BEACON_CTX_H_ */

+ 189 - 0
src/beacon/beacon_rx.c

@@ -0,0 +1,189 @@
+#include "beacon/beacon_rx.h"
+
+#include <string.h>
+
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/atomic.h>
+
+#include "beacon/beacon_ctx.h"
+#include "frame/beacon_frame.h"
+#include "beacon/beacon_work.h"
+#include "frame/frame.h"
+#include "crypto/ident.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+#include "crypto/pubkey_table.h"
+#include "net/time_ctx.h"
+
+LOG_MODULE_REGISTER(beacon_rx, LOG_LEVEL_BEACON_RX);
+
+/* Single-slot, same tradeoff as main.c's rx_work: a second beacon arriving
+ * before this one's work has even started is dropped, logged, not
+ * silently lost - fine at beacon cadence (30 s).
+ */
+static struct {
+	struct k_work work;
+	uint8_t payload[BEACON_PAYLOAD_MAX_LEN];
+	size_t payload_len;
+	struct beacon_ctx_ref bctx;
+	int64_t rx_done_uptime_ms;
+	bool rx_done_valid;
+	atomic_t busy;
+} beacon_rx_work;
+
+static void beacon_rx_work_handler(struct k_work *work)
+{
+	ARG_UNUSED(work);
+
+	LOG_CTX_DBG("work", "payload_len=%zu", beacon_rx_work.payload_len);
+
+	/*
+	 * Central now always appends a real BEACON_SIG_LEN signature after
+	 * the prefix+KV region (see beacon_frame.h/main.c's send_beacon()) -
+	 * every real Beacon on the wire is at least that long. Strip it off
+	 * before decode: beacon_frame_decode() only ever knows the prefix+KV
+	 * layout, and would otherwise misparse the trailing signature bytes
+	 * as bogus extra KV entries (BEACON_SIG_LEN is a multiple of
+	 * BEACON_KV_LEN, so that corruption wouldn't even get caught by the
+	 * length check).
+	 */
+	if (beacon_rx_work.payload_len < BEACON_SIG_LEN) {
+		LOG_WRN("Beacon payload too short for a signature (len=%zu)",
+			beacon_rx_work.payload_len);
+		beacon_ctx_release(beacon_rx_work.bctx);
+		atomic_set(&beacon_rx_work.busy, 0);
+		return;
+	}
+
+	size_t signed_len = beacon_rx_work.payload_len - BEACON_SIG_LEN;
+	const uint8_t *sig = &beacon_rx_work.payload[signed_len];
+
+	struct beacon_frame frame;
+
+	int ret = beacon_frame_decode(beacon_rx_work.payload, signed_len, &frame);
+
+	LOG_CTX_DBG("work/decode", "signed_len=%zu ret=%d", signed_len, ret);
+
+	if (ret < 0) {
+		LOG_WRN("Beacon decode failed (err=%d, len=%zu)", ret, signed_len);
+		beacon_ctx_release(beacon_rx_work.bctx);
+		atomic_set(&beacon_rx_work.busy, 0);
+		return;
+	}
+
+	/*
+	 * Verify against central's pubkey, looked up in whatever table
+	 * beacon_ctx currently has committed (bootstrapped at boot, and
+	 * possibly advanced by an earlier beacon - see beacon_ctx.h). A
+	 * missing table/missing entry fails closed: central_pubkey stays
+	 * all-zero, which ident_verify() will reject (not a valid point on
+	 * the curve) same as any other bad key - CONFIG_DISABLE_PUBKEY_VERIFY
+	 * (see Kconfig) is the only way to bypass that, same as every other
+	 * ident_verify() call site.
+	 */
+	uint8_t central_pubkey[PUBKEY_LEN] = {0};
+
+	bool have_pubkey =
+		beacon_ctx_ref_lookup_pubkey(beacon_rx_work.bctx, FRAME_SRC_CENTRAL, central_pubkey);
+
+	if (!have_pubkey) {
+		LOG_WRN("No pubkey for central (node %u) in current table", FRAME_SRC_CENTRAL);
+	}
+
+	bool verify_ok = ident_verify(beacon_rx_work.payload, signed_len, sig, central_pubkey);
+
+	LOG_CTX_DBG("work/verify", "have_pubkey=%d verify_ok=%d", have_pubkey, verify_ok);
+
+	if (!verify_ok) {
+		LOG_WRN("Beacon ident verify failed (interval_ms=%u)", frame.interval_ms);
+		beacon_ctx_release(beacon_rx_work.bctx);
+		atomic_set(&beacon_rx_work.busy, 0);
+		return;
+	}
+
+	/* One commit: beacon info and (if this beacon advertises a new
+	 * table_id) the pubkey table it names - see beacon_ctx.h for why
+	 * these are tied together.
+	 */
+	beacon_ctx_commit(&frame);
+
+	/*
+	 * Offer central's timestamp as a time_ctx.h time source - 0 means
+	 * central itself had no synced clock when it stamped this beacon
+	 * (see beacon_frame.h), so there's nothing real to offer. Half the
+	 * beacon interval is the uncertainty bound: beacon intervals are
+	 * necessarily orders of magnitude longer than whole-network
+	 * propagation time, so this is a safe starting bound pending real
+	 * measured propagation data from operation.
+	 *
+	 * frame.timestamp_ms is only accurate as of this beacon's RX_DONE
+	 * instant - everything since (queueing onto beacon_work_q, decode,
+	 * ident verify, the beacon_ctx commit above) is local processing
+	 * time that's elapsed without the clock being corrected for it.
+	 * Advance the offered epoch by that elapsed time (RX_DONE's IRQ
+	 * timestamp peek, a k_uptime_get()-comparable ms value, to right
+	 * now) so what we actually hand time_ctx_offer() is "now", not
+	 * "back when the packet arrived". No peek (rx_done_valid false)
+	 * means no correction - better to offer central's timestamp as-is
+	 * than to guess at the elapsed time.
+	 */
+	int64_t elapsed_ms = 0;
+
+	if (beacon_rx_work.rx_done_valid) {
+		elapsed_ms = k_uptime_get() - beacon_rx_work.rx_done_uptime_ms;
+		if (elapsed_ms < 0) {
+			elapsed_ms = 0;
+		}
+	}
+
+	LOG_CTX_DBG("work/time_offer", "timestamp_ms=%llu elapsed_ms=%lld offer=%d",
+		    frame.timestamp_ms, elapsed_ms, frame.timestamp_ms != 0);
+
+	if (frame.timestamp_ms != 0) {
+		time_ctx_offer(TIME_SOURCE_BEACON,
+			       BEACON_EPOCH_UNIX_MS + frame.timestamp_ms + (uint64_t)elapsed_ms,
+			       BEACON_CADENCE_MS / 2);
+	}
+
+	LOG_INF("Beacon ctx updated: interval_ms=%u timestamp_ms=%llu kv_count=%zu",
+		frame.interval_ms, frame.timestamp_ms, frame.kv_count);
+	for (size_t i = 0; i < frame.kv_count; i++) {
+		LOG_INF("  KV: key=0x%03x value=%u", frame.kv_keys[i], frame.kv_values[i]);
+	}
+
+	beacon_ctx_release(beacon_rx_work.bctx);
+	atomic_set(&beacon_rx_work.busy, 0);
+}
+
+void beacon_rx_init(void)
+{
+	k_work_init(&beacon_rx_work.work, beacon_rx_work_handler);
+}
+
+void beacon_rx_submit(const struct rx_ctx *ctx)
+{
+	if (ctx->payload_len > sizeof(beacon_rx_work.payload)) {
+		LOG_ERR("Beacon payload too large to defer (%zu > %zu)", ctx->payload_len,
+			sizeof(beacon_rx_work.payload));
+		beacon_ctx_release(ctx->bctx);
+		return;
+	}
+
+	if (!atomic_cas(&beacon_rx_work.busy, 0, 1)) {
+		LOG_WRN("Beacon RX deferred work still busy, dropping (%zu bytes)",
+			ctx->payload_len);
+		beacon_ctx_release(ctx->bctx);
+		return;
+	}
+
+	memcpy(beacon_rx_work.payload, ctx->payload, ctx->payload_len);
+	beacon_rx_work.payload_len = ctx->payload_len;
+	beacon_rx_work.bctx = ctx->bctx;
+	beacon_rx_work.rx_done_uptime_ms = ctx->rx_done_uptime_ms;
+	beacon_rx_work.rx_done_valid = ctx->rx_done_valid;
+
+	LOG_CTX_DBG("submit", "payload_len=%zu", ctx->payload_len);
+
+	k_work_submit_to_queue(&beacon_work_q, &beacon_rx_work.work);
+}

+ 43 - 0
src/beacon/beacon_rx.h

@@ -0,0 +1,43 @@
+/*
+ * Hands a received Beacon payload off from relay_work_q to beacon_work_q
+ * for decode + ident/pubkey verification, mirroring the same async,
+ * single-slot, drop-if-busy hand-off main.c's own on_rx()->rx_work
+ * already uses crossing the driver/relay_work_q boundary - never call
+ * across queues synchronously (see relay_work.h's deadlock history).
+ */
+#ifndef BEACON_RX_H_
+#define BEACON_RX_H_
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "frame/rx_ctx.h"
+
+/* Must be called once at boot, before any frame can arrive. */
+void beacon_rx_init(void);
+
+/*
+ * Copies ctx->payload[0..payload_len) and submits it to beacon_work_q
+ * for decode/verify. Call only for frames relay_work_q has already
+ * decided this node actually heard (i.e. after frame_decode's group-MAC
+ * check and any SIM_DROP_TX_ID bench-test filtering) - this does not
+ * re-check either. Drops (logs, doesn't block) if the previous
+ * submission hasn't been picked up yet; harmless at beacon cadence,
+ * same tradeoff rx_work already makes.
+ *
+ * Takes ownership of ctx->bctx (see rx_ctx.h): released here, either
+ * immediately if this drops the frame, or at the end of the deferred
+ * work handler otherwise. ctx itself (and everything else it points to)
+ * is only read, and only for the duration of this call - see rx_ctx.h's
+ * own transience note.
+ *
+ * ctx->rx_done_uptime_ms/rx_done_valid are main.c on_rx()'s RX_DONE IRQ
+ * timestamp peek (same one peer_stats.c takes), as a k_uptime_get()-
+ * comparable ms value, used so the deferred work handler can tell how
+ * much local processing time elapsed between the actual over-the-air
+ * RX_DONE instant and whenever it gets around to calling
+ * time_ctx_offer() - see beacon_rx.c's use of it.
+ */
+void beacon_rx_submit(const struct rx_ctx *ctx);
+
+#endif /* BEACON_RX_H_ */

+ 30 - 0
src/beacon/beacon_work.c

@@ -0,0 +1,30 @@
+#include "beacon/beacon_work.h"
+
+/* Sized the same as relay_work_q's own stack: this queue runs
+ * beacon_frame_decode() + a real ident_verify() (ECDSA P-256/SHA-256 via
+ * PSA, plus a beacon_ctx_lookup_pubkey() call, and - on a table_id
+ * change - a blocking pubkey_table_load()) per received Beacon - it was
+ * deliberately not sized minimally for the no-op stub this used to
+ * call, in anticipation of exactly this.
+ */
+#define BEACON_WORK_STACK_SIZE 4096
+
+/* Strictly lower priority than RELAY_WORK_PRIORITY (-1, see relay_work.c)
+ * so this queue never preempts it: relay_work_q's frame decode/group-MAC/
+ * CBF forwarding always gets to run first (or keep running, since -1 is
+ * cooperative) whenever both are ready. This queue's own crypto work
+ * waits, not the other way around - the entire reason it's a separate
+ * queue instead of just more work items on relay_work_q.
+ */
+#define BEACON_WORK_PRIORITY 0
+
+struct k_work_q beacon_work_q;
+static K_THREAD_STACK_DEFINE(beacon_work_stack, BEACON_WORK_STACK_SIZE);
+
+void beacon_work_q_start(void)
+{
+	k_work_queue_start(&beacon_work_q, beacon_work_stack,
+			   K_THREAD_STACK_SIZEOF(beacon_work_stack),
+			   BEACON_WORK_PRIORITY, NULL);
+	k_thread_name_set(k_work_queue_thread_get(&beacon_work_q), "beacon_work");
+}

+ 23 - 0
src/beacon/beacon_work.h

@@ -0,0 +1,23 @@
+/*
+ * A workqueue dedicated to beacon decode + ident/pubkey verification -
+ * kept off relay_work_q so that work (frame decode, group-MAC verify,
+ * CBF forwarding) never has to wait behind this queue's own, which is
+ * only a no-op ident_verify_stub() call today but is sized and
+ * prioritized for the real Ed25519 verify + pubkey lookup that will
+ * eventually land here. That was the whole point of splitting it out
+ * now rather than bolting ident verification onto relay_work_q later:
+ * radio-adjacent timing shouldn't have to get re-tuned around however
+ * expensive real crypto turns out to be. See "key management" /
+ * "workflow design" discussion in project notes.
+ */
+#ifndef BEACON_WORK_H_
+#define BEACON_WORK_H_
+
+#include <zephyr/kernel.h>
+
+/* Must be called once at boot, before scheduling anything onto it. */
+void beacon_work_q_start(void);
+
+extern struct k_work_q beacon_work_q;
+
+#endif /* BEACON_WORK_H_ */

+ 18 - 0
src/crypto/base_ident_pubkey.c

@@ -0,0 +1,18 @@
+#include "crypto/base_ident_pubkey.h"
+
+/*
+ * Base/KDS public key, raw X||Y - generated with
+ *   uv run scripts/build_ident_key.py --out firmware/relay-zephyr/lfs-seed-base/ident/privkey.bin
+ * The matching private scalar lives only in lfs-seed-base/ident/privkey.bin
+ * (not under any lfs-seed-nodeN directory, so it never gets flashed to a
+ * device by build_littlefs_image.py) and is used offline by
+ * scripts/build_pubkey_table.py --sign-with to sign pubkey tables.
+ */
+const uint8_t base_ident_pubkey[IDENT_PUBKEY_LEN] = {
+	0xcd, 0x9c, 0xcb, 0xa8, 0x64, 0xf7, 0x2b, 0xe5, 0xbc, 0xdd, 0xaf, 0x0c,
+	0xd2, 0x06, 0xd4, 0x2c, 0xb2, 0xb5, 0x63, 0x62, 0x36, 0x63, 0x2b, 0x92,
+	0xfb, 0x91, 0xc0, 0x59, 0x73, 0x9a, 0xb3, 0xb2, 0x7d, 0xab, 0xba, 0x1b,
+	0xcb, 0xab, 0x6e, 0x0f, 0xce, 0xdb, 0x67, 0xdc, 0x2c, 0x45, 0xd8, 0x72,
+	0x8c, 0x45, 0xbc, 0xdf, 0x44, 0x9a, 0xd4, 0x3b, 0xca, 0x82, 0x1d, 0xf4,
+	0x03, 0x63, 0x6c, 0xe9,
+};

+ 17 - 0
src/crypto/base_ident_pubkey.h

@@ -0,0 +1,17 @@
+/*
+ * Compiled-in base/KDS identity public key - the one trust root
+ * pubkey_table_load() hardcodes rather than looking up anywhere, since
+ * at load time no pubkey table has been verified yet to look anything
+ * up in (see "pubkey table signing" project notes). Generate the
+ * matching keypair with scripts/build_ident_key.py and bake its printed
+ * pubkey hex in here via base_ident_pubkey.c; the private half never
+ * leaves the base station's own flash/secrets and is never checked in.
+ */
+#ifndef BASE_IDENT_PUBKEY_H_
+#define BASE_IDENT_PUBKEY_H_
+
+#include "crypto/ident.h"
+
+extern const uint8_t base_ident_pubkey[IDENT_PUBKEY_LEN];
+
+#endif /* BASE_IDENT_PUBKEY_H_ */

+ 7 - 0
src/crypto/group_key.c

@@ -0,0 +1,7 @@
+#include "crypto/group_key.h"
+
+/* TEMPORARY: arbitrary fixed bytes, not a real provisioned secret. */
+const uint8_t group_key[GROUP_KEY_LEN] = {
+	0x4c, 0x6f, 0x52, 0x61, 0x2d, 0x43, 0x46, 0x42,
+	0x2d, 0x74, 0x65, 0x73, 0x74, 0x2d, 0x6b, 0x30,
+};

+ 16 - 0
src/crypto/group_key.h

@@ -0,0 +1,16 @@
+/*
+ * TEMPORARY: hardcoded group key, no key exchange yet. Lets the group-MAC
+ * code path (compute/verify) get built and exercised now; swap for the
+ * real slow-cycle handshake output once that lands (see the Broadcast
+ * Integrity companion doc).
+ */
+#ifndef GROUP_KEY_H_
+#define GROUP_KEY_H_
+
+#include <stdint.h>
+
+#define GROUP_KEY_LEN 16
+
+extern const uint8_t group_key[GROUP_KEY_LEN];
+
+#endif /* GROUP_KEY_H_ */

+ 87 - 0
src/crypto/group_mac.c

@@ -0,0 +1,87 @@
+#include "crypto/group_mac.h"
+#include "crypto/group_key.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+
+#include <string.h>
+
+#include <psa/crypto.h>
+
+#include <zephyr/logging/log.h>
+#include <zephyr/toolchain.h>
+LOG_MODULE_REGISTER(group_mac, LOG_LEVEL_GROUP_MAC);
+
+static psa_key_id_t key_id;
+
+int group_mac_init(void)
+{
+	psa_status_t status = psa_crypto_init();
+
+	if (status != PSA_SUCCESS) {
+		LOG_ERR("psa_crypto_init failed: %d", status);
+		return -EIO;
+	}
+
+	psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT;
+
+	psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_SIGN_MESSAGE | PSA_KEY_USAGE_VERIFY_MESSAGE);
+	psa_set_key_algorithm(&attr, PSA_ALG_HMAC(PSA_ALG_SHA_256));
+	psa_set_key_type(&attr, PSA_KEY_TYPE_HMAC);
+	psa_set_key_bits(&attr, GROUP_KEY_LEN * 8);
+
+	status = psa_import_key(&attr, group_key, GROUP_KEY_LEN, &key_id);
+	if (status != PSA_SUCCESS) {
+		LOG_ERR("psa_import_key failed: %d", status);
+		return -EIO;
+	}
+
+	LOG_INF("Group MAC ready (HMAC-SHA256, truncated to %d B, hardcoded key)", GROUP_MAC_LEN);
+#ifdef CONFIG_DISABLE_MAC_VERIFY
+	LOG_WRN("CONFIG_DISABLE_MAC_VERIFY set - group_mac_verify() always passes. TEST BUILD ONLY.");
+#endif
+	return 0;
+}
+
+int group_mac_compute(const uint8_t *data, size_t len, uint8_t *mac_out)
+{
+	uint8_t full[32];
+	size_t full_len;
+
+	psa_status_t status = psa_mac_compute(key_id, PSA_ALG_HMAC(PSA_ALG_SHA_256), data, len,
+					       full, sizeof(full), &full_len);
+	if (status != PSA_SUCCESS) {
+		LOG_ERR("psa_mac_compute failed: %d", status);
+		return -EIO;
+	}
+
+	memcpy(mac_out, full, GROUP_MAC_LEN);
+	LOG_CTX_DBG("compute", "len=%zu", len);
+	return 0;
+}
+
+bool group_mac_verify(const uint8_t *data, size_t len, const uint8_t *mac_in)
+{
+#ifdef CONFIG_DISABLE_MAC_VERIFY
+	ARG_UNUSED(data);
+	ARG_UNUSED(len);
+	ARG_UNUSED(mac_in);
+	LOG_CTX_DBG("verify", "len=%zu CONFIG_DISABLE_MAC_VERIFY=1 result=1", len);
+	return true;
+#else
+	uint8_t expected[GROUP_MAC_LEN];
+
+	if (group_mac_compute(data, len, expected) < 0) {
+		LOG_CTX_DBG("verify", "len=%zu compute_failed=1 result=0", len);
+		return false;
+	}
+
+	/* GROUP_MAC_LEN is fixed and tiny; this is close enough to
+	 * constant-time for a truncated MAC at this length, and we don't
+	 * yet have the auth doc's final comparison requirements.
+	 */
+	bool result = memcmp(expected, mac_in, GROUP_MAC_LEN) == 0;
+
+	LOG_CTX_DBG("verify", "len=%zu result=%d", len, result);
+	return result;
+#endif
+}

+ 49 - 0
src/crypto/group_mac.h

@@ -0,0 +1,49 @@
+/*
+ * Group HMAC framework: real HMAC-SHA256 (via PSA crypto), truncated to
+ * GROUP_MAC_LEN bytes, keyed by the hardcoded group_key for now. This is
+ * the actual membership gate the relay logic checks on every frame.
+ */
+#ifndef GROUP_MAC_H_
+#define GROUP_MAC_H_
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+/*
+ * 4 bytes, fixed for every PTYPE - settled, not provisional. The
+ * original "4-6 B" range in the envelope doc was for a design with
+ * per-PTYPE MAC lengths; nothing in this codebase varies it anymore
+ * (every CBF relay hop recomputes the same GROUP_MAC_LEN trailer
+ * uniformly), so there's no reason left to keep it a range.
+ *
+ * 32 bits of truncated HMAC is airtime-bound, not compute-bound, to
+ * attack: at SF10/BW500/CR4.6 a short frame costs ~100ms to transmit,
+ * so even a generous 10 forgery attempts/sec over the air takes ~6.8
+ * years to reach 50% cumulative forgery probability (2^32 attempts);
+ * even 1000/sec - already ~100x faster than airtime physically allows
+ * - still takes ~25 days. That's far more margin than this MAC's actual
+ * job needs: it's a cheap membership gate ("additive, not a stand-in
+ * for identity signing" - see main.c), not the real authentication
+ * layer (that's Ed25519 ident signing). 6 B would cost 2 more bytes of
+ * airtime on every single frame for no threat this MAC is meant to
+ * cover.
+ */
+#define GROUP_MAC_LEN 4
+
+int group_mac_init(void);
+
+/* Computes the truncated MAC over data[0..len) into mac_out[0..GROUP_MAC_LEN). */
+int group_mac_compute(const uint8_t *data, size_t len, uint8_t *mac_out);
+
+/*
+ * Recomputes and constant-time-compares against mac_in.
+ *
+ * CONFIG_DISABLE_MAC_VERIFY (see Kconfig) makes this always return true
+ * without computing anything - bench/test builds only. A build with
+ * that set gets "-NOAUTH" appended to APP_BUILD_ID (CMakeLists.txt) so
+ * it's visible in the boot log, not just in the build config.
+ */
+bool group_mac_verify(const uint8_t *data, size_t len, const uint8_t *mac_in);
+
+#endif /* GROUP_MAC_H_ */

+ 132 - 0
src/crypto/ident.c

@@ -0,0 +1,132 @@
+#include "crypto/ident.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+
+#include <errno.h>
+#include <string.h>
+
+#include <psa/crypto.h>
+
+#include <zephyr/logging/log.h>
+#include <zephyr/toolchain.h>
+LOG_MODULE_REGISTER(ident, LOG_LEVEL_IDENT);
+
+int ident_init(void)
+{
+	psa_status_t status = psa_crypto_init();
+
+	if (status != PSA_SUCCESS) {
+		LOG_ERR("psa_crypto_init failed: %d", status);
+		return -EIO;
+	}
+
+	LOG_INF("Ident ready (ECDSA P-256/SHA-256 via PSA)");
+#ifdef CONFIG_DISABLE_PUBKEY_VERIFY
+	LOG_WRN("CONFIG_DISABLE_PUBKEY_VERIFY set - ident_verify() always passes. TEST BUILD ONLY.");
+#endif
+	return 0;
+}
+
+/*
+ * Shared import step for both directions: everything backend-specific
+ * about a key (PSA's type tag, the SEC1 point encoding a public key
+ * needs) lives only here, so ident_verify()/ident_sign() themselves
+ * just move opaque buffers around.
+ */
+static psa_status_t import_pubkey(const uint8_t pubkey[IDENT_PUBKEY_LEN], psa_key_id_t *key_id)
+{
+	/* PSA wants the standard SEC1 uncompressed-point encoding (format
+	 * byte 0x04 + X + Y) for ECC_PUBLIC_KEY import - see ident.h for
+	 * why that byte isn't carried in pubkey itself.
+	 */
+	uint8_t sec1_pubkey[1 + IDENT_PUBKEY_LEN];
+
+	sec1_pubkey[0] = 0x04;
+	memcpy(&sec1_pubkey[1], pubkey, IDENT_PUBKEY_LEN);
+
+	psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT;
+
+	psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_VERIFY_MESSAGE);
+	psa_set_key_algorithm(&attr, PSA_ALG_ECDSA(PSA_ALG_SHA_256));
+	psa_set_key_type(&attr, PSA_KEY_TYPE_ECC_PUBLIC_KEY(PSA_ECC_FAMILY_SECP_R1));
+	psa_set_key_bits(&attr, 256);
+
+	return psa_import_key(&attr, sec1_pubkey, sizeof(sec1_pubkey), key_id);
+}
+
+static psa_status_t import_privkey(const uint8_t privkey[IDENT_PRIVKEY_LEN], psa_key_id_t *key_id)
+{
+	psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT;
+
+	psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_SIGN_MESSAGE);
+	psa_set_key_algorithm(&attr, PSA_ALG_ECDSA(PSA_ALG_SHA_256));
+	psa_set_key_type(&attr, PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_SECP_R1));
+	psa_set_key_bits(&attr, 256);
+
+	return psa_import_key(&attr, privkey, IDENT_PRIVKEY_LEN, key_id);
+}
+
+bool ident_verify(const uint8_t *data, size_t len, const uint8_t sig[IDENT_SIG_LEN],
+		   const uint8_t pubkey[IDENT_PUBKEY_LEN])
+{
+#ifdef CONFIG_DISABLE_PUBKEY_VERIFY
+	ARG_UNUSED(data);
+	ARG_UNUSED(len);
+	ARG_UNUSED(sig);
+	ARG_UNUSED(pubkey);
+	LOG_CTX_DBG("verify", "len=%zu CONFIG_DISABLE_PUBKEY_VERIFY=1 result=1", len);
+	return true;
+#else
+	psa_key_id_t key_id;
+	psa_status_t status = import_pubkey(pubkey, &key_id);
+
+	if (status != PSA_SUCCESS) {
+		LOG_WRN("import_pubkey failed: %d", status);
+		LOG_CTX_DBG("verify", "len=%zu import_pubkey_status=%d result=0", len, status);
+		return false;
+	}
+
+	status = psa_verify_message(key_id, PSA_ALG_ECDSA(PSA_ALG_SHA_256), data, len, sig,
+				     IDENT_SIG_LEN);
+
+	psa_destroy_key(key_id);
+
+	if (status != PSA_SUCCESS) {
+		LOG_WRN("psa_verify_message failed: %d", status);
+		LOG_CTX_DBG("verify", "len=%zu verify_status=%d result=0", len, status);
+		return false;
+	}
+
+	LOG_CTX_DBG("verify", "len=%zu result=1", len);
+	return true;
+#endif
+}
+
+int ident_sign(const uint8_t *data, size_t len, const uint8_t privkey[IDENT_PRIVKEY_LEN],
+	       uint8_t sig_out[IDENT_SIG_LEN])
+{
+	psa_key_id_t key_id;
+	psa_status_t status = import_privkey(privkey, &key_id);
+
+	if (status != PSA_SUCCESS) {
+		LOG_WRN("import_privkey failed: %d", status);
+		LOG_CTX_DBG("sign", "len=%zu import_privkey_status=%d ret=-EIO", len, status);
+		return -EIO;
+	}
+
+	size_t sig_len;
+
+	status = psa_sign_message(key_id, PSA_ALG_ECDSA(PSA_ALG_SHA_256), data, len, sig_out,
+				   IDENT_SIG_LEN, &sig_len);
+
+	psa_destroy_key(key_id);
+
+	if (status != PSA_SUCCESS) {
+		LOG_WRN("psa_sign_message failed: %d", status);
+		LOG_CTX_DBG("sign", "len=%zu sign_status=%d ret=-EIO", len, status);
+		return -EIO;
+	}
+
+	LOG_CTX_DBG("sign", "len=%zu sig_len=%zu ret=0", len, sig_len);
+	return 0;
+}

+ 83 - 0
src/crypto/ident.h

@@ -0,0 +1,83 @@
+/*
+ * Identity-signature primitives: sign/verify over an opaque data buffer,
+ * keyed by opaque fixed-length byte vectors. This header is the single
+ * place that names the current backend's sizes (IDENT_PUBKEY_LEN/
+ * IDENT_PRIVKEY_LEN/IDENT_SIG_LEN) - every other file in the app
+ * (pubkey_table.c/h, base_ident_pubkey.c/h, main.c, and eventually
+ * beacon signing/verification) only ever moves byte buffers of those
+ * lengths around; none of them know or care what curve/algorithm fills
+ * them.
+ *
+ * Current backend: ECDSA P-256/SHA-256 via PSA crypto (see ident.c's own
+ * comment for why not the design doc's Ed25519 - this Zephyr/mbedTLS
+ * checkout has no PSA EdDSA support). ident.c is the only file that
+ * knows that; it owns the PSA key-type tags, the SEC1 point encoding,
+ * everything backend-specific.
+ *
+ * Swapping backends later (candidates noted in project memory: wolfSSL,
+ * which is already a first-class Zephyr module and has EdDSA; or a
+ * vendored Monocypher/libsodium/TweetNaCl) means changing the three
+ * #defines below to match the new backend's key/signature sizes and
+ * rewriting ident.c's body against the new library's API - nothing
+ * else in the app should need to change. pubkey_table.h's on-flash
+ * wire format does change size when these do (its entry stride is
+ * sized off IDENT_PUBKEY_LEN), but pubkey_table.c's own logic (parse,
+ * lookup, load) doesn't - it already treats PUBKEY_LEN as an opaque
+ * parameter, not a hardcoded number.
+ */
+#ifndef IDENT_H_
+#define IDENT_H_
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+/*
+ * Raw X||Y coordinates (64 B), no SEC1 format-byte prefix on flash/wire -
+ * ident_verify() prepends the 0x04 uncompressed-point byte itself before
+ * handing a key to PSA, since that byte is always the same value and
+ * not worth the on-air/on-flash cost of carrying.
+ */
+#define IDENT_PUBKEY_LEN 64
+
+/* Raw P-256 private scalar. */
+#define IDENT_PRIVKEY_LEN 32
+
+/* Raw r||s, 32 B each, big-endian - not DER. Also used by ident_stub.h's
+ * always-true/zero-fill stand-ins, so every signed payload in the app
+ * (stubbed or real) carries the same trailer size regardless of which
+ * path produced/checks it.
+ */
+#define IDENT_SIG_LEN 64
+
+/* One-time PSA crypto init. psa_crypto_init() is itself idempotent, so
+ * this has no once-guard of its own; call it before the first
+ * ident_sign()/ident_verify().
+ */
+int ident_init(void);
+
+/*
+ * Verifies a signature over data[0..len) against pubkey. Imports pubkey
+ * as an ephemeral PSA key per call - this runs at table-load time, not
+ * per packet, so there's no reuse to amortize yet.
+ *
+ * CONFIG_DISABLE_PUBKEY_VERIFY (see Kconfig) makes this always return
+ * true without touching PSA at all - bench/test builds only. A build
+ * with that set gets "-NOAUTH" appended to APP_BUILD_ID (CMakeLists.txt)
+ * so it's visible in the boot log, not just in the build config.
+ */
+bool ident_verify(const uint8_t *data, size_t len, const uint8_t sig[IDENT_SIG_LEN],
+		   const uint8_t pubkey[IDENT_PUBKEY_LEN]);
+
+/*
+ * Signs data[0..len) with privkey, writing IDENT_SIG_LEN bytes to
+ * sig_out. Mirrors ident_verify(): same ephemeral-per-call PSA key
+ * import, same "not on the per-packet path yet" scope. Not wired to any
+ * call site yet (beacon signing is still ahead - see project notes);
+ * added now so sign/verify stay a symmetric pair against one backend
+ * instead of verify arriving alone and sign bolted on ad hoc later.
+ */
+int ident_sign(const uint8_t *data, size_t len, const uint8_t privkey[IDENT_PRIVKEY_LEN],
+	       uint8_t sig_out[IDENT_SIG_LEN]);
+
+#endif /* IDENT_H_ */

+ 51 - 0
src/crypto/ident_privkey.c

@@ -0,0 +1,51 @@
+#include "crypto/ident_privkey.h"
+#include "log_levels.h"
+
+#include <errno.h>
+#include <string.h>
+
+#include <zephyr/fs/fs.h>
+#include <zephyr/logging/log.h>
+LOG_MODULE_REGISTER(ident_privkey, LOG_LEVEL_IDENT_PRIVKEY);
+
+int ident_privkey_load(uint8_t privkey_out[IDENT_PRIVKEY_LEN])
+{
+	struct fs_file_t file;
+
+	fs_file_t_init(&file);
+
+	int ret = fs_open(&file, IDENT_PRIVKEY_PATH, FS_O_READ);
+
+	if (ret < 0) {
+		if (ret == -ENOENT) {
+			LOG_WRN("fs_open(%s): not found", IDENT_PRIVKEY_PATH);
+		} else {
+			LOG_ERR("fs_open(%s) failed: %d", IDENT_PRIVKEY_PATH, ret);
+		}
+		return ret;
+	}
+
+	/* One read, no loop - same reasoning as pubkey_table_load(): this
+	 * file is always exactly IDENT_PRIVKEY_LEN bytes, far under a
+	 * single fs_read's worth.
+	 */
+	uint8_t buf[IDENT_PRIVKEY_LEN + 1];
+	ssize_t n = fs_read(&file, buf, sizeof(buf));
+
+	fs_close(&file);
+
+	if (n < 0) {
+		LOG_ERR("fs_read(%s) failed: %zd", IDENT_PRIVKEY_PATH, n);
+		return (int)n;
+	}
+
+	if (n != IDENT_PRIVKEY_LEN) {
+		LOG_ERR("%s is %zd B, expected exactly %d - provisioning error",
+			IDENT_PRIVKEY_PATH, n, IDENT_PRIVKEY_LEN);
+		return -EINVAL;
+	}
+
+	memcpy(privkey_out, buf, IDENT_PRIVKEY_LEN);
+	LOG_INF("Loaded privkey from %s", IDENT_PRIVKEY_PATH);
+	return 0;
+}

+ 31 - 0
src/crypto/ident_privkey.h

@@ -0,0 +1,31 @@
+/*
+ * Loads this node's own long-term private key from littlefs - the one
+ * key a node actually holds for itself, as opposed to pubkey_table.h's
+ * directory of *other* nodes' public keys. Only central (NODE_ID ==
+ * FRAME_SRC_CENTRAL) calls this today, to sign outgoing Beacon frames
+ * (see main.c) - every node is seeded with its own privkey.bin (see
+ * scripts/build_ident_key.py), but nothing else signs anything yet.
+ */
+#ifndef IDENT_PRIVKEY_H_
+#define IDENT_PRIVKEY_H_
+
+#include <stdint.h>
+
+#include "crypto/ident.h"
+
+/* Mirrors pubkey_table.h's PUBKEY_TABLE_MOUNT_POINT/DIR naming - see
+ * build_littlefs_image.py: a seed dir's ident/privkey.bin lands here.
+ */
+#define IDENT_PRIVKEY_MOUNT_POINT "/lfs"
+#define IDENT_PRIVKEY_PATH IDENT_PRIVKEY_MOUNT_POINT "/ident/privkey.bin"
+
+/*
+ * Reads exactly IDENT_PRIVKEY_LEN bytes from IDENT_PRIVKEY_PATH into
+ * privkey_out. Returns 0 on success, -ENOENT if the file doesn't exist,
+ * -EINVAL if it's the wrong size (a provisioning error, not a runtime
+ * one - this never happens from anything on the wire), or another
+ * negative errno from the fs layer.
+ */
+int ident_privkey_load(uint8_t privkey_out[IDENT_PRIVKEY_LEN]);
+
+#endif /* IDENT_PRIVKEY_H_ */

+ 20 - 0
src/crypto/ident_stub.c

@@ -0,0 +1,20 @@
+#include "crypto/ident_stub.h"
+
+#include <string.h>
+
+#include <zephyr/toolchain.h>
+
+void ident_sign_stub(const uint8_t *data, size_t len, uint8_t *sig_out)
+{
+	ARG_UNUSED(data);
+	ARG_UNUSED(len);
+	memset(sig_out, 0, IDENT_SIG_LEN);
+}
+
+bool ident_verify_stub(const uint8_t *data, size_t len, const uint8_t *sig_in)
+{
+	ARG_UNUSED(data);
+	ARG_UNUSED(len);
+	ARG_UNUSED(sig_in);
+	return true;
+}

+ 24 - 0
src/crypto/ident_stub.h

@@ -0,0 +1,24 @@
+/*
+ * Null identity signature / faked verify, for call sites real signing
+ * hasn't reached yet (currently beacon_rx.c, discovery_rx.c - see
+ * main.c). Not wired to any live traffic yet in this milestone (Beacon
+ * is riding the group MAC as a practical stand-in) - this is the hook
+ * for Discovery/JOIN work. pubkey_table.c has already moved to the real
+ * ident_verify() in ident.h; this stub is what's left to migrate.
+ */
+#ifndef IDENT_STUB_H_
+#define IDENT_STUB_H_
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+#include "crypto/ident.h" /* IDENT_SIG_LEN - one definition shared with the real backend, so a stubbed payload and a really-signed one carry the same trailer size */
+
+/* Zero-fills sig_out[0..IDENT_SIG_LEN). */
+void ident_sign_stub(const uint8_t *data, size_t len, uint8_t *sig_out);
+
+/* Always returns true. */
+bool ident_verify_stub(const uint8_t *data, size_t len, const uint8_t *sig_in);
+
+#endif /* IDENT_STUB_H_ */

+ 140 - 0
src/crypto/pubkey_table.c

@@ -0,0 +1,140 @@
+#include "crypto/pubkey_table.h"
+#include "crypto/base_ident_pubkey.h"
+#include "crypto/ident.h"
+#include "log_levels.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <string.h>
+
+#include <zephyr/fs/fs.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/byteorder.h>
+
+/* DBG, not just INF: this whole on-flash format is untested on real
+ * hardware - see the DBG calls below for exactly what that buys. The
+ * level passed here is a compile-time cap (this isn't a Zephyr-
+ * subsystem module with its own Kconfig_LOG_LEVEL choice), so it has to
+ * be DBG itself for those calls to survive at all, independent of
+ * whatever the runtime default ends up filtering.
+ */
+LOG_MODULE_REGISTER(pubkey_table, LOG_LEVEL_PUBKEY_TABLE);
+
+#define PUBKEY_TABLE_HDR_LEN	8
+#define PUBKEY_TABLE_ENTRY_LEN	(1 + PUBKEY_LEN)
+#define PUBKEY_TABLE_FMT_VERSION 0
+
+int pubkey_table_parse(const uint8_t *data, size_t len, uint32_t expect_table_id,
+			struct pubkey_table *table)
+{
+	if (len < PUBKEY_TABLE_HDR_LEN + IDENT_SIG_LEN) {
+		LOG_WRN("table too short (%zu B)", len);
+		return -EINVAL;
+	}
+
+	if (data[0] != 'P' || data[1] != 'K') {
+		LOG_WRN("bad magic - first 8 B: %02x %02x %02x %02x %02x %02x %02x %02x",
+			data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]);
+		return -EINVAL;
+	}
+
+	if (data[2] != PUBKEY_TABLE_FMT_VERSION) {
+		LOG_WRN("unsupported format version %u", data[2]);
+		return -EINVAL;
+	}
+
+	uint32_t table_id = sys_get_be24(&data[3]);
+
+	if (table_id != expect_table_id) {
+		LOG_WRN("table_id mismatch (file says %u, expected %u)", table_id,
+			expect_table_id);
+		return -EINVAL;
+	}
+
+	uint16_t entry_count = sys_get_be16(&data[6]);
+	size_t entries_len = (size_t)entry_count * PUBKEY_TABLE_ENTRY_LEN;
+	size_t sig_off = PUBKEY_TABLE_HDR_LEN + entries_len;
+
+	if (sig_off + IDENT_SIG_LEN != len) {
+		LOG_WRN("length mismatch (entry_count=%u implies %zu B, got %zu B)",
+			entry_count, sig_off + IDENT_SIG_LEN, len);
+		return -EINVAL;
+	}
+
+	if (!ident_verify(data, sig_off, &data[sig_off], base_ident_pubkey)) {
+		LOG_WRN("signature verify failed (table_id=%u)", table_id);
+		return -EINVAL;
+	}
+
+	table->data = data;
+	table->len = len;
+	table->table_id = table_id;
+	table->entry_count = entry_count;
+
+	return 0;
+}
+
+bool pubkey_table_lookup(const struct pubkey_table *table, uint8_t node_id,
+			  uint8_t pubkey_out[PUBKEY_LEN])
+{
+	const uint8_t *entries = table->data + PUBKEY_TABLE_HDR_LEN;
+
+	for (uint16_t i = 0; i < table->entry_count; i++) {
+		const uint8_t *entry = entries + i * PUBKEY_TABLE_ENTRY_LEN;
+
+		if (entry[0] == node_id) {
+			memcpy(pubkey_out, &entry[1], PUBKEY_LEN);
+			return true;
+		}
+	}
+
+	return false;
+}
+
+int pubkey_table_load(uint32_t table_id, uint8_t *buf, size_t buf_len,
+		       struct pubkey_table *table)
+{
+	char path[PUBKEY_TABLE_PATH_LEN];
+
+	snprintf(path, sizeof(path), PUBKEY_TABLE_DIR "/table.%05x", table_id);
+
+	LOG_DBG("loading %s", path);
+
+	struct fs_file_t file;
+
+	fs_file_t_init(&file);
+
+	int ret = fs_open(&file, path, FS_O_READ);
+
+	if (ret < 0) {
+		if (ret == -ENOENT) {
+			LOG_DBG("fs_open(%s): not found", path);
+		} else {
+			LOG_WRN("fs_open(%s) failed: %d", path, ret);
+		}
+		return ret;
+	}
+
+	/* One read, no loop: table files are tiny (a header + a few dozen
+	 * PUBKEY_TABLE_ENTRY_LEN-sized entries + an IDENT_SIG_LEN signature
+	 * at most, at today's node counts - see pubkey_table.h) relative to
+	 * any buf_len callers would plausibly pass, so this never spans
+	 * multiple fs_read calls. If the file is
+	 * actually bigger than buf_len, this returns a truncated buf_len
+	 * bytes - pubkey_table_parse then rejects it on the entry_count/
+	 * length mismatch, same as any other corrupt file; load() doesn't
+	 * need to special-case that itself.
+	 */
+	ssize_t n = fs_read(&file, buf, buf_len);
+
+	fs_close(&file);
+
+	if (n < 0) {
+		LOG_WRN("fs_read(%s) failed: %zd", path, n);
+		return (int)n;
+	}
+
+	LOG_DBG("read %zd B from %s", n, path);
+
+	return pubkey_table_parse(buf, (size_t)n, table_id, table);
+}

+ 121 - 0
src/crypto/pubkey_table.h

@@ -0,0 +1,121 @@
+/*
+ * On-flash (and, once the in-band DATA-frame push lands, on-air) format for
+ * a versioned peer pubkey table: /lfs/pubkey/table.NNNNN, where NNNNN is
+ * the same 20-bit table_id Beacon's 0x001/0x002 KV keys carry (see
+ * main.c) - lowercase hex, zero-padded to 5 digits so it sorts/reads
+ * sanely as a filename regardless of the numeric value. Fixed-stride
+ * entries, no TLV, so parsing is pure offset arithmetic - no allocation,
+ * no multi-pass scan. See "key management" discussion in project notes
+ * for why: tables are additive (new node joins -> entry appended) and a
+ * stale table is still mostly trustworthy, so cheap linear lookup beats a
+ * sorted/indexed structure. PUBKEY_TABLE_MAX_LOAD_LEN below sizes for the
+ * full node_id address space (node_id is a uint8_t, see frame.h) now that
+ * beacon_ctx.c's pool holding these tables lives in PSRAM (PSRAM_BSS, see
+ * psram_bss.h) - no reason to size tight against internal SRAM once the
+ * actual storage isn't competing for it. Revisit the linear-scan lookup
+ * itself (not the sizing) if real node counts ever grow enough for that
+ * to matter.
+ *
+ * Wire layout (sizes in terms of ident.h's own constants, not literal
+ * numbers - this table's entry/signature sizes move with whatever
+ * backend ident.h names, see its header comment):
+ *   0                    2              magic ("PK")
+ *   2                    1              format version (wire layout
+ *                                       only, distinct from table_id -
+ *                                       a content identity, not a
+ *                                       schema one)
+ *   3                    3              table_id (BE, 20 bits used/4
+ *                                       top bits reserved - redundant
+ *                                       against the filename on
+ *                                       purpose; the file's own bytes
+ *                                       must self-identify, independent
+ *                                       of whatever path it was read
+ *                                       from)
+ *   6                    2              entry_count (BE uint16)
+ *   8                    ENTRY_LEN*N    entries[N]: node_id(1) +
+ *                                       pubkey(PUBKEY_LEN, opaque - see
+ *                                       ident.h)
+ *   8+ENTRY_LEN*N        IDENT_SIG_LEN  signature (base/KDS privkey,
+ *                                       opaque - see ident.h - over
+ *                                       bytes [0, 8+ENTRY_LEN*N))
+ *
+ * The signature is checked via ident_verify() against the compiled-in
+ * base_ident_pubkey - at load time no other key is trusted yet, so this
+ * is the one case that hardcodes/trusts a single key rather than looking
+ * one up in a table (see base_ident_pubkey.h). ident_verify_stub() still
+ * covers every payload type this milestone hasn't reached yet - see
+ * ident_stub.h.
+ */
+#ifndef PUBKEY_TABLE_H_
+#define PUBKEY_TABLE_H_
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+#include "crypto/ident.h"
+
+#define PUBKEY_LEN IDENT_PUBKEY_LEN
+
+/* table_id is a 20-bit space (matches Beacon's KV value field) - 0xFFFFF
+ * is therefore a real, assignable id, so the "no table loaded" sentinel
+ * has to live outside that range rather than at one end of it.
+ */
+#define PUBKEY_TABLE_ID_MAX 0xFFFFF
+#define PUBKEY_TABLE_ID_NONE 0xFFFFFFFFUL
+
+/* Where tables live on the littlefs_partition mount (see boards/
+ * esp32s3_devkitc_esp32s3_procpu.overlay's "lfs" fstab node).
+ */
+#define PUBKEY_TABLE_MOUNT_POINT "/lfs"
+#define PUBKEY_TABLE_DIR PUBKEY_TABLE_MOUNT_POINT "/pubkey"
+
+/* Longest path pubkey_table_load() builds ("/lfs/pubkey/table.NNNNN\0") -
+ * sized for whoever wants a stack buffer to pass it a path, though the
+ * function builds its own internally.
+ */
+#define PUBKEY_TABLE_PATH_LEN (sizeof(PUBKEY_TABLE_DIR "/table.NNNNN"))
+
+/* Buffer size for pubkey_table_load() callers: header(8) + one entry
+ * per possible node_id (1 + PUBKEY_LEN B each, node_id's full uint8_t
+ * range) + signature(64) - the actual ceiling, not a headroom guess;
+ * see this file's header comment on why sizing for that is fine now.
+ */
+#define PUBKEY_TABLE_MAX_ENTRIES 256
+#define PUBKEY_TABLE_MAX_LOAD_LEN (8 + PUBKEY_TABLE_MAX_ENTRIES * (1 + PUBKEY_LEN) + 64)
+
+struct pubkey_table {
+	const uint8_t *data; /* Not owned - caller's buffer must outlive this. */
+	size_t len;
+	uint32_t table_id;
+	uint16_t entry_count;
+};
+
+/*
+ * Validates magic/format/table_id/signature and fills out table on success.
+ * expect_table_id is whatever the caller already believes this file should
+ * be (e.g. parsed from its own filename) - a mismatch against the table_id
+ * carried inside the file itself is treated as corrupt/untrusted, not
+ * silently trusted from the path. data must remain valid for table's
+ * lifetime (table only stores a pointer into it, not a copy).
+ */
+int pubkey_table_parse(const uint8_t *data, size_t len, uint32_t expect_table_id,
+			struct pubkey_table *table);
+
+/* Linear scan over table's fixed-stride entries. Returns false if node_id
+ * isn't present.
+ */
+bool pubkey_table_lookup(const struct pubkey_table *table, uint8_t node_id,
+			  uint8_t pubkey_out[PUBKEY_LEN]);
+
+/*
+ * Reads PUBKEY_TABLE_DIR "/table.<table_id, 5-digit lowercase hex>" from
+ * flash into buf (caller-owned, must outlive table - same ownership rule
+ * as pubkey_table_parse) and parses it. Returns 0 on success, -ENOENT if
+ * the file doesn't exist, or another negative errno from the fs layer or
+ * from pubkey_table_parse on a corrupt/malicious file.
+ */
+int pubkey_table_load(uint32_t table_id, uint8_t *buf, size_t buf_len,
+		       struct pubkey_table *table);
+
+#endif /* PUBKEY_TABLE_H_ */

+ 111 - 0
src/frame/beacon_frame.c

@@ -0,0 +1,111 @@
+#include "frame/beacon_frame.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+
+#include <errno.h>
+
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/__assert.h>
+#include <zephyr/sys/byteorder.h>
+
+LOG_MODULE_REGISTER(beacon_frame, LOG_LEVEL_BEACON_FRAME);
+
+static void put_be24(uint8_t *buf, uint32_t val)
+{
+	buf[0] = (uint8_t)(val >> 16);
+	buf[1] = (uint8_t)(val >> 8);
+	buf[2] = (uint8_t)val;
+}
+
+static uint32_t get_be24(const uint8_t *buf)
+{
+	return ((uint32_t)buf[0] << 16) | ((uint32_t)buf[1] << 8) | buf[2];
+}
+
+static void put_be40(uint8_t *buf, uint64_t val)
+{
+	buf[0] = (uint8_t)(val >> 32);
+	buf[1] = (uint8_t)(val >> 24);
+	buf[2] = (uint8_t)(val >> 16);
+	buf[3] = (uint8_t)(val >> 8);
+	buf[4] = (uint8_t)val;
+}
+
+static uint64_t get_be40(const uint8_t *buf)
+{
+	return ((uint64_t)buf[0] << 32) | ((uint64_t)buf[1] << 24) |
+	       ((uint64_t)buf[2] << 16) | ((uint64_t)buf[3] << 8) | buf[4];
+}
+
+static void beacon_kv_put(uint8_t *buf, uint16_t key, uint32_t value)
+{
+	sys_put_be32(((uint32_t)key << BEACON_KV_VALUE_BITS) | (value & BEACON_KV_VALUE_MASK), buf);
+}
+
+static void beacon_kv_get(const uint8_t *buf, uint16_t *key, uint32_t *value)
+{
+	uint32_t word = sys_get_be32(buf);
+
+	*key = (uint16_t)(word >> BEACON_KV_VALUE_BITS);
+	*value = word & BEACON_KV_VALUE_MASK;
+}
+
+size_t beacon_frame_encode(uint8_t *buf, uint32_t interval_ms, uint64_t timestamp_ms,
+			    const uint16_t *kv_keys, const uint32_t *kv_values, size_t kv_count)
+{
+	__ASSERT_NO_MSG(kv_count <= BEACON_MAX_KV_PAIRS);
+
+	put_be24(&buf[0], interval_ms);
+	put_be40(&buf[3], timestamp_ms);
+
+	size_t len = BEACON_PAYLOAD_FIXED_LEN;
+
+	for (size_t i = 0; i < kv_count; i++) {
+		beacon_kv_put(&buf[len], kv_keys[i], kv_values[i]);
+		len += BEACON_KV_LEN;
+	}
+
+	LOG_CTX_DBG("encode", "interval_ms=%u timestamp_ms=%llu kv_count=%zu len=%zu", interval_ms,
+		    timestamp_ms, kv_count, len);
+
+	return len;
+}
+
+int beacon_frame_decode(const uint8_t *payload, size_t payload_len, struct beacon_frame *out)
+{
+	if (payload_len < BEACON_PAYLOAD_FIXED_LEN) {
+		LOG_CTX_DBG("decode", "payload_len=%zu min=%d ret=-EINVAL", payload_len,
+			    BEACON_PAYLOAD_FIXED_LEN);
+		return -EINVAL;
+	}
+
+	size_t kv_len = payload_len - BEACON_PAYLOAD_FIXED_LEN;
+
+	if (kv_len % BEACON_KV_LEN != 0) {
+		LOG_CTX_DBG("decode", "kv_len=%zu not a multiple of %d ret=-EINVAL", kv_len,
+			    BEACON_KV_LEN);
+		return -EINVAL;
+	}
+
+	size_t kv_count = kv_len / BEACON_KV_LEN;
+
+	if (kv_count > BEACON_MAX_KV_PAIRS) {
+		LOG_CTX_DBG("decode", "kv_count=%zu max=%d ret=-EINVAL", kv_count,
+			    BEACON_MAX_KV_PAIRS);
+		return -EINVAL;
+	}
+
+	out->interval_ms = get_be24(&payload[0]);
+	out->timestamp_ms = get_be40(&payload[3]);
+	out->kv_count = kv_count;
+
+	for (size_t i = 0; i < kv_count; i++) {
+		beacon_kv_get(&payload[BEACON_PAYLOAD_FIXED_LEN + i * BEACON_KV_LEN],
+			      &out->kv_keys[i], &out->kv_values[i]);
+	}
+
+	LOG_CTX_DBG("decode", "interval_ms=%u timestamp_ms=%llu kv_count=%zu ret=0",
+		    out->interval_ms, out->timestamp_ms, kv_count);
+
+	return 0;
+}

+ 132 - 0
src/frame/beacon_frame.h

@@ -0,0 +1,132 @@
+/*
+ * Beacon payload wire format - shared by main.c's TX encode (send_beacon)
+ * and beacon_rx.c's RX decode, so both sides agree on layout from one
+ * place. A fixed timing prefix followed by zero or more 4 B packed
+ * key/value entries - the extensible mechanism for everything added
+ * after this milestone (table ids today, whatever flags/versions come
+ * later) without a format change each time. A key this firmware doesn't
+ * recognize is skipped, not an error - that's what makes it safe to add
+ * new keys incrementally across a mixed-firmware deployment.
+ *
+ * Fixed prefix:
+ *   interval_ms  3 B  ms until the next beacon (today: just the fixed
+ *                      BEACON_CADENCE_MS; no schedule drift modeling
+ *                      yet).
+ *   timestamp_ms 5 B  ms since BEACON_EPOCH_UNIX_MS - a ~34-year span,
+ *                      long enough that rollover is outside a device's
+ *                      life. 0 (not "unsynced" per se - just the epoch
+ *                      itself) until central's own wall clock has
+ *                      synced (time_ctx.h).
+ *
+ * KV entries, 4 B each, BE: top 12 bits key, bottom 20 bits value
+ * (BEACON_KV_KEY_BITS/BEACON_KV_VALUE_MASK). Key 0x000 is reserved - never
+ * assigned, so a stray all-zero word (padding, a truncated read) can't be
+ * mistaken for a real entry. Keys currently defined:
+ *   0x001  current pubkey table (see pubkey_table.h) this node trusts.
+ *          Omitted entirely (not sent with a sentinel value) until table
+ *          load/provisioning exists - an absent key means "no claim",
+ *          which is the point of this being a KV scheme rather than
+ *          fixed fields.
+ *   0x002  an older table_id still safe to validate against (no revoked
+ *          entries) - advisory, so a node running behind 0x001 still
+ *          trusts peers it already has rather than treating itself as
+ *          untrusted. May repeat (multiple 0x002 entries = multiple
+ *          still-safe fallbacks).
+ *
+ * After the KV region: BEACON_SIG_LEN bytes, central's signature (via
+ * ident_sign() in main.c) over everything before it (the fixed prefix +
+ * KV region, i.e. beacon_frame_encode()'s own output) - not over the
+ * frame header or the group MAC trailer those ride outside this payload
+ * entirely. beacon_frame_decode() never sees these bytes; the caller
+ * strips them first (see beacon_rx.c).
+ */
+#ifndef BEACON_FRAME_H_
+#define BEACON_FRAME_H_
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "frame/frame.h"
+#include "crypto/group_mac.h"
+#include "crypto/ident.h"
+
+/* Scheduling cadence - also the basis of the per-source uncertainty
+ * beacon_rx.c offers time_ctx.h for beacon-derived time (half the
+ * cadence; see project notes on why that's a reasonable starting bound
+ * - beacon intervals are necessarily orders of magnitude longer than
+ * whole-network propagation time). Moved here from main.c since it's
+ * as much a protocol constant (every Beacon's own interval_ms field)
+ * as a scheduling choice.
+ */
+#define BEACON_CADENCE_MS 30000
+
+/* 2026-01-01T00:00:00Z, the epoch timestamp_ms counts from. */
+#define BEACON_EPOCH_UNIX_MS 1767225600000ULL
+
+#define BEACON_PAYLOAD_FIXED_LEN 8
+#define BEACON_KV_LEN 4
+#define BEACON_KV_KEY_BITS 12
+#define BEACON_KV_VALUE_BITS 20
+#define BEACON_KV_VALUE_MASK ((1UL << BEACON_KV_VALUE_BITS) - 1)
+#define BEACON_KEY_RESERVED 0x000
+#define BEACON_KEY_TABLE_CURRENT 0x001
+#define BEACON_KEY_TABLE_ALT 0x002
+
+/*
+ * Signature trailer length - appended on the wire after the KV region,
+ * same as pubkey_table.h's own trailing-signature layout. This file
+ * only knows the length (so BEACON_MAX_KV_PAIRS/BEACON_PAYLOAD_MAX_LEN
+ * below size correctly); it never touches key material or calls
+ * ident_sign()/ident_verify() itself - that's main.c (TX, central only)
+ * and beacon_rx.c (RX)'s job. beacon_frame_encode()/beacon_frame_decode()
+ * only ever see the prefix+KV region, never the signature bytes - the
+ * caller is responsible for appending (TX) or stripping (RX) exactly
+ * BEACON_SIG_LEN bytes around those calls.
+ */
+#define BEACON_SIG_LEN IDENT_SIG_LEN
+
+/* Largest number of KV pairs a Beacon can carry within FRAME_MAX_LEN,
+ * alongside the signature trailer - derived, not hand-picked, so it
+ * tracks FRAME_MAX_LEN/BEACON_SIG_LEN if either ever changes.
+ */
+#define BEACON_MAX_KV_PAIRS \
+	((FRAME_MAX_LEN - FRAME_HDR_LEN - GROUP_MAC_LEN - BEACON_PAYLOAD_FIXED_LEN - BEACON_SIG_LEN) \
+	 / BEACON_KV_LEN)
+
+/* Largest full wire payload (prefix + KV + signature trailer) - what a
+ * caller's buffer needs to hold end to end. beacon_frame_encode() itself
+ * only fills/returns the prefix+KV prefix of this.
+ */
+#define BEACON_PAYLOAD_MAX_LEN \
+	(BEACON_PAYLOAD_FIXED_LEN + BEACON_MAX_KV_PAIRS * BEACON_KV_LEN + BEACON_SIG_LEN)
+
+struct beacon_frame {
+	uint32_t interval_ms;
+	uint64_t timestamp_ms;
+	uint16_t kv_keys[BEACON_MAX_KV_PAIRS];
+	uint32_t kv_values[BEACON_MAX_KV_PAIRS];
+	size_t kv_count;
+};
+
+/*
+ * Encodes into buf (caller-sized >= BEACON_PAYLOAD_MAX_LEN). kv_count must
+ * not exceed BEACON_MAX_KV_PAIRS - callers building from a fixed-size
+ * source array (e.g. main.c's alt_table_ids) already can't exceed it, so
+ * this asserts rather than returning an error.
+ * Returns the encoded payload length.
+ */
+size_t beacon_frame_encode(uint8_t *buf, uint32_t interval_ms, uint64_t timestamp_ms,
+			    const uint16_t *kv_keys, const uint32_t *kv_values, size_t kv_count);
+
+/*
+ * Decodes payload (the frame's payload region, as frame_decode() returns
+ * it) into out. Returns 0 on success, -EINVAL if payload_len is too short
+ * for the fixed prefix, if the trailing KV region isn't a whole number of
+ * 4 B entries, or if it holds more entries than out->kv_keys/kv_values can
+ * hold. Every KV entry is stored regardless of key - filtering by key
+ * (e.g. "only care about table ids") is the caller's job, not this
+ * function's.
+ */
+int beacon_frame_decode(const uint8_t *payload, size_t payload_len, struct beacon_frame *out);
+
+#endif /* BEACON_FRAME_H_ */

+ 120 - 0
src/frame/frame.c

@@ -0,0 +1,120 @@
+#include "frame/frame.h"
+#include "crypto/group_mac.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+
+#include <errno.h>
+#include <string.h>
+
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/byteorder.h>
+
+LOG_MODULE_REGISTER(frame, LOG_LEVEL_FRAME);
+
+#define CTRL_VER_SHIFT   6
+#define CTRL_DIR_SHIFT   5
+#define CTRL_RELAY_SHIFT 4
+#define CTRL_PTYPE_SHIFT 1
+
+static uint8_t encode_ctrl(const struct frame_hdr *hdr)
+{
+	return (uint8_t)((hdr->ver & 0x3) << CTRL_VER_SHIFT) |
+	       (uint8_t)((hdr->dir & 0x1) << CTRL_DIR_SHIFT) |
+	       (uint8_t)((hdr->relay & 0x1) << CTRL_RELAY_SHIFT) |
+	       (uint8_t)((hdr->ptype & 0x7) << CTRL_PTYPE_SHIFT);
+}
+
+static void decode_ctrl(uint8_t ctrl, struct frame_hdr *hdr)
+{
+	hdr->ver = (ctrl >> CTRL_VER_SHIFT) & 0x3;
+	hdr->dir = (ctrl >> CTRL_DIR_SHIFT) & 0x1;
+	hdr->relay = (ctrl >> CTRL_RELAY_SHIFT) & 0x1;
+	hdr->ptype = (ctrl >> CTRL_PTYPE_SHIFT) & 0x7;
+}
+
+int frame_encode(uint8_t *buf, size_t buf_len, const struct frame_hdr *hdr,
+		  const uint8_t *payload, size_t payload_len)
+{
+	size_t total = FRAME_HDR_LEN + payload_len + GROUP_MAC_LEN;
+
+	LOG_CTX_DBG("encode", "tx=%u src=%u dst=%u seq=%u dir=%d relay=%d ptype=%d payload_len=%zu",
+		    hdr->tx, hdr->src, hdr->dst, hdr->seq, hdr->dir, hdr->relay, hdr->ptype,
+		    payload_len);
+
+	if (buf_len < total) {
+		LOG_CTX_DBG("encode", "buf_len=%zu total=%zu ret=-ENOMEM", buf_len, total);
+		return -ENOMEM;
+	}
+
+	buf[0] = encode_ctrl(hdr);
+	buf[1] = hdr->tx;
+	buf[2] = hdr->src;
+	buf[3] = hdr->dst;
+	sys_put_be16(hdr->seq, &buf[4]);
+
+	if (payload_len > 0) {
+		memcpy(&buf[FRAME_HDR_LEN], payload, payload_len);
+	}
+
+	int ret = group_mac_compute(buf, FRAME_HDR_LEN + payload_len,
+				     &buf[FRAME_HDR_LEN + payload_len]);
+	if (ret < 0) {
+		LOG_CTX_DBG("encode", "group_mac_compute ret=%d", ret);
+		return ret;
+	}
+
+	return (int)total;
+}
+
+int frame_decode_hdr(const uint8_t *buf, size_t len, struct frame_hdr *hdr,
+		      const uint8_t **payload, size_t *payload_len)
+{
+	if (len < FRAME_HDR_LEN + GROUP_MAC_LEN) {
+		LOG_CTX_DBG("decode_hdr", "len=%zu min=%d ret=-EINVAL", len,
+			    FRAME_HDR_LEN + GROUP_MAC_LEN);
+		return -EINVAL;
+	}
+
+	decode_ctrl(buf[0], hdr);
+	hdr->tx = buf[1];
+	hdr->src = buf[2];
+	hdr->dst = buf[3];
+	hdr->seq = sys_get_be16(&buf[4]);
+
+	*payload = &buf[FRAME_HDR_LEN];
+	*payload_len = len - FRAME_HDR_LEN - GROUP_MAC_LEN;
+
+	LOG_CTX_DBG("decode_hdr", "tx=%u src=%u dst=%u seq=%u dir=%d relay=%d ptype=%d "
+		    "payload_len=%zu", hdr->tx, hdr->src, hdr->dst, hdr->seq, hdr->dir,
+		    hdr->relay, hdr->ptype, *payload_len);
+
+	return 0;
+}
+
+bool frame_group_mac_ok(const uint8_t *buf, size_t len)
+{
+	size_t body_len = len - GROUP_MAC_LEN;
+	bool ok = group_mac_verify(buf, body_len, &buf[body_len]);
+
+	LOG_CTX_DBG("group_mac_ok", "len=%zu ok=%d", len, ok);
+
+	return ok;
+}
+
+int frame_restamp_tx(uint8_t *buf, size_t len, uint8_t new_tx)
+{
+	if (len < FRAME_HDR_LEN + GROUP_MAC_LEN) {
+		LOG_CTX_DBG("restamp_tx", "len=%zu min=%d ret=-EINVAL", len,
+			    FRAME_HDR_LEN + GROUP_MAC_LEN);
+		return -EINVAL;
+	}
+
+	buf[1] = new_tx;
+
+	size_t body_len = len - GROUP_MAC_LEN;
+	int ret = group_mac_compute(buf, body_len, &buf[body_len]);
+
+	LOG_CTX_DBG("restamp_tx", "new_tx=%u ret=%d", new_tx, ret);
+
+	return ret;
+}

+ 116 - 0
src/frame/frame.h

@@ -0,0 +1,116 @@
+/*
+ * Frame envelope per "Relay Transport and Directional Forwarding":
+ * CTRL(1) | TX(1) | SRC(1) | DST(1) | SEQ(2) | payload | group MAC.
+ * SEQ is this header field - the design doc calls it MSG, renamed here
+ * to not collide with PTYPE_MSG (frame_ptype's own catch-all type,
+ * below), and because it's a per-origin sequence counter in the same
+ * spirit TCP uses the term.
+ *
+ * This milestone only builds/parses PTYPE_BEACON, with a minimal payload
+ * (cadence_ms) - no coarse time, mode flags, or version ids yet. Beacon is
+ * riding the group MAC as a practical stand-in for its real Ed25519
+ * signature until ident signing exists (see main.c for why).
+ */
+#ifndef FRAME_H_
+#define FRAME_H_
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+#include "crypto/group_mac.h"
+
+#define FRAME_HDR_LEN	6
+#define FRAME_DST_ALL	0xFF
+#define FRAME_SRC_CENTRAL 0x00
+
+/* Largest raw on-air frame (header + payload + group MAC) this milestone
+ * ever builds or buffers - shared so every fixed-size raw-frame buffer
+ * (CBF's forward buffer, the RX deferred-work buffer) uses one number.
+ *
+ * 250, not a carefully-derived budget yet: the real ceiling is airtime/
+ * duty-cycle, which varies with SF/BW (see main.c's LORA_BENCH_TEST) and
+ * hasn't been worked out. Bumped from the original bring-up placeholder
+ * of 64 because that number couldn't even fit a signed Beacon (a raw
+ * ECDSA P-256 signature alone is IDENT_SIG_LEN=64 B - see ident.h/
+ * beacon_frame.h) - 250 is itself still a placeholder, expect this to
+ * get revisited once real airtime/duty-cycle numbers exist.
+ */
+#define FRAME_MAX_LEN	250
+
+/* Largest payload any PTYPE can carry - everything in FRAME_MAX_LEN minus
+ * the header and the trailing group MAC. Shared so every per-PTYPE RX
+ * buffer (discovery_rx.c, payload_rx.c) sizes off the same number instead
+ * of each re-deriving it.
+ */
+#define FRAME_MAX_PAYLOAD_LEN (FRAME_MAX_LEN - FRAME_HDR_LEN - GROUP_MAC_LEN)
+
+enum frame_dir {
+	FRAME_DIR_DOWNSTREAM = 0,
+	FRAME_DIR_UPSTREAM = 1,
+};
+
+/* Active-high: relay=1 reads as "yes, relay this" at a glance in a raw
+ * hex/log dump, and an unset/zeroed bit fails closed to DIRECT_ONLY
+ * (no flood) rather than silently defaulting to "please relay".
+ */
+enum frame_relay {
+	FRAME_RELAY_DIRECT_ONLY = 0,
+	FRAME_RELAY_REQUESTED = 1,
+};
+
+enum frame_ptype {
+	PTYPE_BEACON = 0,
+	PTYPE_DISCOVERY = 1,
+	PTYPE_MSG = 2,
+	PTYPE_DATA = 3,
+	PTYPE_SECRET = 4,
+};
+
+struct frame_hdr {
+	uint8_t ver;
+	enum frame_dir dir;
+	enum frame_relay relay;
+	enum frame_ptype ptype;
+	uint8_t tx;
+	uint8_t src;
+	uint8_t dst;
+	uint16_t seq;
+};
+
+/* Encodes hdr + payload[0..payload_len) + a freshly-computed group MAC
+ * into buf. Returns the total frame length, or < 0 on error.
+ */
+int frame_encode(uint8_t *buf, size_t buf_len, const struct frame_hdr *hdr,
+		  const uint8_t *payload, size_t payload_len);
+
+/*
+ * Decodes buf into hdr and points payload/payload_len at the payload
+ * region (still inside buf, not copied). Does NOT check the group MAC -
+ * callers that need that (everything except PTYPE_DISCOVERY's bypass,
+ * see main.c's rx dispatch) call frame_group_mac_ok() separately. Kept
+ * apart from the MAC check (rather than one combined decode-and-verify
+ * call, as this used to be) because dispatch needs to see hdr.ptype
+ * before deciding whether a bad MAC is even disqualifying - a brand-new
+ * node announcing itself via PTYPE_DISCOVERY has no group key yet, so it
+ * can't produce a valid group MAC at all, and is authenticated by its
+ * ident signature instead (once that's wired - still a stub today).
+ * Returns 0 on success, or < 0 if buf is too short to hold a header and
+ * group MAC trailer.
+ */
+int frame_decode_hdr(const uint8_t *buf, size_t len, struct frame_hdr *hdr,
+		      const uint8_t **payload, size_t *payload_len);
+
+/* Checks buf's trailing group MAC against its own header+payload. buf/len
+ * are the same raw frame frame_decode_hdr() was called with (not the
+ * payload region alone).
+ */
+bool frame_group_mac_ok(const uint8_t *buf, size_t len);
+
+/* Rewrites just the TX field of an already-encoded frame (buf, as
+ * produced by frame_encode/received raw) to new_tx and recomputes the
+ * group MAC in place. Used by the relay path - see cbf.c.
+ */
+int frame_restamp_tx(uint8_t *buf, size_t len, uint8_t new_tx);
+
+#endif /* FRAME_H_ */

+ 63 - 0
src/frame/msg_frame.c

@@ -0,0 +1,63 @@
+#include "frame/msg_frame.h"
+
+#include <errno.h>
+#include <string.h>
+
+#include <zephyr/sys/byteorder.h>
+
+size_t msg_frame_encode(uint8_t *buf, uint16_t subtype, const uint8_t *data, size_t data_len)
+{
+	sys_put_be16((uint16_t)(subtype << MSG_SUBTYPE_SHIFT), buf);
+
+	if (data_len > 0) {
+		memcpy(&buf[MSG_SUBTYPE_HDR_LEN], data, data_len);
+	}
+
+	return MSG_SUBTYPE_HDR_LEN + data_len;
+}
+
+int msg_frame_decode(const uint8_t *payload, size_t payload_len, struct msg_frame *out)
+{
+	if (payload_len < MSG_SUBTYPE_HDR_LEN) {
+		return -EINVAL;
+	}
+
+	uint16_t raw = sys_get_be16(payload);
+
+	out->subtype = raw >> MSG_SUBTYPE_SHIFT;
+	out->data = &payload[MSG_SUBTYPE_HDR_LEN];
+	out->data_len = payload_len - MSG_SUBTYPE_HDR_LEN;
+
+	return 0;
+}
+
+int msg_time_code_encode(uint8_t *buf, const uint8_t nonce[MSG_TIME_PULSE_DATA_LEN],
+			  uint64_t tx_epoch_ms, uint32_t uncertainty_ms,
+			  const uint8_t privkey[IDENT_PRIVKEY_LEN])
+{
+	memcpy(buf, nonce, MSG_TIME_PULSE_DATA_LEN);
+	sys_put_be64(tx_epoch_ms, &buf[MSG_TIME_PULSE_DATA_LEN]);
+	sys_put_be32(uncertainty_ms, &buf[MSG_TIME_PULSE_DATA_LEN + 8]);
+
+	int ret = ident_sign(buf, MSG_TIME_CODE_SIGNED_LEN, privkey, &buf[MSG_TIME_CODE_SIGNED_LEN]);
+
+	if (ret < 0) {
+		return ret;
+	}
+
+	return MSG_TIME_CODE_DATA_LEN;
+}
+
+int msg_time_code_decode(const uint8_t *data, size_t data_len, struct msg_time_code *out)
+{
+	if (data_len != MSG_TIME_CODE_DATA_LEN) {
+		return -EINVAL;
+	}
+
+	memcpy(out->nonce, data, MSG_TIME_PULSE_DATA_LEN);
+	out->tx_epoch_ms = sys_get_be64(&data[MSG_TIME_PULSE_DATA_LEN]);
+	out->uncertainty_ms = sys_get_be32(&data[MSG_TIME_PULSE_DATA_LEN + 8]);
+	out->sig = &data[MSG_TIME_CODE_SIGNED_LEN];
+
+	return 0;
+}

+ 119 - 0
src/frame/msg_frame.h

@@ -0,0 +1,119 @@
+/*
+ * PTYPE_MSG payload wire format - the sub-type framing the design doc
+ * calls out as an open item ("Msg sub-type registry and each sub-type's
+ * payload"). Msg is the catch-all PTYPE, so unlike Beacon's fixed
+ * prefix+KV shape, every sub-type's payload is its own free-form blob;
+ * this file only owns the 2 B discriminator in front of it.
+ *
+ * Layout, BE: top 12 bits sub-type (MSG_SUBTYPE_BITS), bottom 4 bits
+ * reserved (must be 0 on encode; not yet validated on decode - future
+ * flag bits can land there without a format change, same spirit as
+ * Beacon's "unrecognized key is skipped, not an error"). 12 bits mirrors
+ * beacon_frame.h's own KV key width (0x000-0xFFF, ~4k sub-types) rather
+ * than introducing a second, differently-sized id space - 2 B instead of
+ * beacon's packed 4 B KV entry only because a sub-type's payload is
+ * variable-length, not a fixed 20-bit value, so it needs to start on its
+ * own byte boundary. Sub-type 0x000 is reserved, same reason as Beacon's
+ * key 0x000: a stray all-zero payload can't be mistaken for a real one.
+ */
+#ifndef MSG_FRAME_H_
+#define MSG_FRAME_H_
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "crypto/ident.h"
+
+#define MSG_SUBTYPE_HDR_LEN 2
+#define MSG_SUBTYPE_BITS    12
+#define MSG_SUBTYPE_SHIFT   4
+#define MSG_SUBTYPE_RESERVED 0x000
+
+/*
+ * Ping/pong: the first real sub-type, mainly for network exercising -
+ * not a functional requirement yet. data is 2 B of the sender's own
+ * choosing (a nonce); whichever node this frame's DST names replies
+ * with Pong carrying the same 2 B back, unicast to Ping's SRC.
+ */
+#define MSG_SUBTYPE_PING 0x001
+#define MSG_SUBTYPE_PONG 0x002
+#define MSG_PING_DATA_LEN 2
+
+/*
+ * Two-step time-sync (design in local_time_sync_plan; not wired up yet -
+ * no sender or dispatch calls these, this is just the payload shape to
+ * build on next). Step 1, TimePulse: a bare 2 B nonce of the sender's
+ * own choosing, broadcast with no claim about time yet - its only job is
+ * to give TX_DONE a moment every Rx node can timestamp on its own clock.
+ * Step 2, TimeCode: sent after TX_DONE of the TimePulse that carried the
+ * same nonce, carrying the sender's own observed TX-DONE time (so Rx
+ * nodes can compare their TimePulse-RX timestamp against it) and the
+ * sender's uncertainty in that observation (same estimate+uncertainty
+ * shape as time_ctx_offer(), so a verified TimeCode can feed one
+ * straight in). Ident-signed over tx_epoch_ms+uncertainty_ms+nonce so a
+ * receiver can validate exactly which node sent it without needing
+ * group-mac to trust the claim.
+ */
+#define MSG_SUBTYPE_TIME_PULSE 0x003
+#define MSG_TIME_PULSE_DATA_LEN 2 /* nonce */
+
+#define MSG_SUBTYPE_TIME_CODE 0x004
+
+struct msg_time_code {
+	uint8_t nonce[MSG_TIME_PULSE_DATA_LEN]; /* nonce of the TimePulse this answers */
+	uint64_t tx_epoch_ms;   /* sender's wall-clock estimate of its TimePulse TX_DONE */
+	uint32_t uncertainty_ms; /* sender's own bound on tx_epoch_ms, time_ctx_offer()-shaped */
+	const uint8_t *sig;     /* IDENT_SIG_LEN bytes, points into the caller's buffer */
+};
+
+/* Portion of msg_time_code that's covered by the ident signature:
+ * nonce || tx_epoch_ms (BE) || uncertainty_ms (BE), signature trailing.
+ */
+#define MSG_TIME_CODE_SIGNED_LEN (MSG_TIME_PULSE_DATA_LEN + 8 + 4)
+#define MSG_TIME_CODE_DATA_LEN   (MSG_TIME_CODE_SIGNED_LEN + IDENT_SIG_LEN)
+
+/*
+ * Encodes a TimeCode's data (the part after msg_frame's subtype header)
+ * into buf (caller-sized >= MSG_TIME_CODE_DATA_LEN): nonce/tx_epoch_ms/
+ * uncertainty_ms, ident_sign()'d with privkey over MSG_TIME_CODE_SIGNED_LEN
+ * bytes, signature appended. Returns MSG_TIME_CODE_DATA_LEN on success, a
+ * negative ident_sign() errno on failure.
+ */
+int msg_time_code_encode(uint8_t *buf, const uint8_t nonce[MSG_TIME_PULSE_DATA_LEN],
+			  uint64_t tx_epoch_ms, uint32_t uncertainty_ms,
+			  const uint8_t privkey[IDENT_PRIVKEY_LEN]);
+
+/*
+ * Decodes a TimeCode's data (as msg_frame_decode() hands back via
+ * out->data/out->data_len for MSG_SUBTYPE_TIME_CODE) into out. out->sig
+ * points back into data, not copied. Does not call ident_verify() itself
+ * - that needs the claimed sender's pubkey, which is the caller's to look
+ * up (e.g. via pubkey_table). Returns 0 on success, -EINVAL if data_len
+ * != MSG_TIME_CODE_DATA_LEN.
+ */
+int msg_time_code_decode(const uint8_t *data, size_t data_len, struct msg_time_code *out);
+
+struct msg_frame {
+	uint16_t subtype;
+	const uint8_t *data; /* Points into the caller's payload buffer, not copied. */
+	size_t data_len;
+};
+
+/*
+ * Encodes the MSG_SUBTYPE_HDR_LEN discriminator followed by
+ * data[0..data_len) into buf (caller-sized >= MSG_SUBTYPE_HDR_LEN +
+ * data_len). subtype must fit MSG_SUBTYPE_BITS (0x000-0xFFF); callers
+ * pass one of the MSG_SUBTYPE_* constants, never a raw value from the
+ * wire. Returns the encoded length.
+ */
+size_t msg_frame_encode(uint8_t *buf, uint16_t subtype, const uint8_t *data, size_t data_len);
+
+/*
+ * Decodes payload (the frame's payload region, as frame_decode_hdr()
+ * returns it) into out. out->data points back into payload, not copied
+ * - same lifetime as payload. Returns 0 on success, -EINVAL if
+ * payload_len is shorter than MSG_SUBTYPE_HDR_LEN.
+ */
+int msg_frame_decode(const uint8_t *payload, size_t payload_len, struct msg_frame *out);
+
+#endif /* MSG_FRAME_H_ */

+ 17 - 0
src/frame/pkt_log.c

@@ -0,0 +1,17 @@
+#include "frame/pkt_log.h"
+
+#include "frame/frame.h"
+#include "log_levels.h"
+
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/util.h>
+
+LOG_MODULE_REGISTER(pkt_log, LOG_LEVEL_PKT_LOG);
+
+void pkt_log_raw(const char *label, const uint8_t *buf, size_t len)
+{
+	char hex[FRAME_MAX_LEN * 2 + 1];
+
+	bin2hex(buf, len, hex, sizeof(hex));
+	LOG_DBG("%s: %s", label, hex);
+}

+ 29 - 0
src/frame/pkt_log.h

@@ -0,0 +1,29 @@
+/*
+ * Shared raw-packet hex dump for the RX and TX single callpoints
+ * (main.c's rx_work_handler, tx_queue.c's process_item) - one place logs
+ * raw on-air bytes, in or out, same as main.c's dispatch is the one
+ * place that logs "a frame was received" and tx_queue is the one place
+ * that logs "a frame was sent". Packet consumers (beacon_rx, cbf,
+ * payload_rx, ...) never log raw bytes themselves.
+ *
+ * Its own log module/level (LOG_LEVEL_PKT_LOG, see log_levels.h) is
+ * deliberately separate from LOG_LEVEL_MAIN/LOG_LEVEL_TX_QUEUE: flipping
+ * it to DBG traces every raw frame crossing the radio without also
+ * pulling in main.c's or tx_queue.c's own unrelated DBG tracing, and
+ * vice versa.
+ */
+#ifndef PKT_LOG_H_
+#define PKT_LOG_H_
+
+#include <stddef.h>
+#include <stdint.h>
+
+/* Logs buf[0..len) as a single-line, no-delimiter hex dump (greps/diffs
+ * as one token), tagged with label (e.g. "RX raw", "TX raw"). len must
+ * not exceed FRAME_MAX_LEN (see frame.h) - every caller today already
+ * enforces that on its own buffer before this point. Compiles out
+ * entirely unless LOG_LEVEL_PKT_LOG is LOG_LEVEL_DBG.
+ */
+void pkt_log_raw(const char *label, const uint8_t *buf, size_t len);
+
+#endif /* PKT_LOG_H_ */

+ 55 - 0
src/frame/rx_ctx.h

@@ -0,0 +1,55 @@
+/*
+ * Everything main.c's rx_work_handler knows about one received frame by
+ * the time it dispatches to a per-PTYPE submit_*() call (beacon_rx.h,
+ * discovery_rx.h, payload_rx.h) - bundled into one struct instead of
+ * each submit_*() growing its own parameter list as a handler needs one
+ * more piece of it. A consumer that only needs payload/payload_len
+ * still just uses those fields; nothing is forced to read the rest.
+ *
+ * Transient: valid only for the duration of the synchronous submit_*()
+ * call that receives it - ctx->payload points into main.c's own
+ * single-slot rx_work.buf, which is free to be reused for the next
+ * frame the moment rx_work_handler returns. A submit_*() that defers to
+ * its own work queue (every one of them today) must copy out whatever
+ * fields it needs before returning, same as the pre-rx_ctx code did.
+ *
+ * bctx is the one field that's more than just a read: ownership
+ * transfers to whichever single submit_*() call takes this ctx, per
+ * beacon_ctx.h - that call must release it, either immediately if it
+ * drops the frame or at the end of its own deferred work otherwise.
+ */
+#ifndef RX_CTX_H_
+#define RX_CTX_H_
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+#include "beacon/beacon_ctx.h"
+#include "frame/frame.h"
+
+struct rx_ctx {
+	const struct frame_hdr *hdr;
+	const uint8_t *payload;
+	size_t payload_len;
+
+	/* Whether this frame's group MAC checked out - see main.c's rx
+	 * dispatch for why Discovery is the one PTYPE a consumer ever
+	 * sees this false for.
+	 */
+	bool mac_ok;
+
+	/* RX_DONE IRQ timestamp peek (sx126x_irq_timestamp_get()) as a ms
+	 * value - a plain unit conversion, not a reconciliation, since the
+	 * driver itself already resolves that peek to an absolute,
+	 * k_uptime_get()-comparable ns value (see zephyr-patches/0001).
+	 * rx_done_valid=false (rx_done_uptime_ms then just a
+	 * k_uptime_get() fallback) if main.c's on_rx() didn't get a peek.
+	 */
+	int64_t rx_done_uptime_ms;
+	bool rx_done_valid;
+
+	struct beacon_ctx_ref bctx;
+};
+
+#endif /* RX_CTX_H_ */

+ 65 - 0
src/frame/rx_dedup.c

@@ -0,0 +1,65 @@
+#include "log_ctx.h"
+#include "log_levels.h"
+#include "frame/rx_dedup.h"
+
+#include <string.h>
+
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+
+LOG_MODULE_REGISTER(rx_dedup, LOG_LEVEL_RX_DEDUP);
+
+/* Same size/TTL as cbf.c's own seen[] - no particular reason to differ,
+ * see this module's header comment on why it's a separate table.
+ */
+#define RX_DEDUP_SET_SIZE 8
+#define RX_DEDUP_TTL_MS	  60000
+
+struct rx_dedup_entry {
+	uint8_t src;
+	uint16_t seq;
+	int64_t at_ms;
+	bool valid;
+};
+
+static struct rx_dedup_entry seen[RX_DEDUP_SET_SIZE];
+
+void rx_dedup_init(void)
+{
+	memset(seen, 0, sizeof(seen));
+	LOG_CTX_DBG("init", "seen set cleared");
+}
+
+bool rx_dedup_check_and_add(uint8_t src, uint16_t seq)
+{
+	int64_t now = k_uptime_get();
+
+	for (int i = 0; i < RX_DEDUP_SET_SIZE; i++) {
+		if (seen[i].valid && (now - seen[i].at_ms) > RX_DEDUP_TTL_MS) {
+			seen[i].valid = false;
+		}
+		if (seen[i].valid && seen[i].src == src && seen[i].seq == seq) {
+			LOG_CTX_DBG("check", "src=%u seq=%u duplicate=1 slot=%d", src, seq, i);
+			return true;
+		}
+	}
+
+	/* Not found: add it, evicting the oldest slot if full. */
+	int oldest = 0;
+
+	for (int i = 0; i < RX_DEDUP_SET_SIZE; i++) {
+		if (!seen[i].valid) {
+			oldest = i;
+			break;
+		}
+		if (seen[i].at_ms < seen[oldest].at_ms) {
+			oldest = i;
+		}
+	}
+
+	seen[oldest] = (struct rx_dedup_entry){
+		.src = src, .seq = seq, .at_ms = now, .valid = true,
+	};
+	LOG_CTX_DBG("check", "src=%u seq=%u duplicate=0 slot=%d", src, seq, oldest);
+	return false;
+}

+ 34 - 0
src/frame/rx_dedup.h

@@ -0,0 +1,34 @@
+/*
+ * Recent-RX log keyed by (src, seq) - gates only whether a frame reaches
+ * the per-PTYPE RX handlers (beacon/discovery/payload), not whether CBF
+ * considers relaying it. The same logical message can validly reach a
+ * node more than once (direct + one or more relay hops hearing the same
+ * original transmission, or more than one relayer echoing it) - CBF
+ * needs to see every one of those copies to do its own relay-scheduling
+ * and forward-disarm bookkeeping, but the application-level handlers
+ * should only ever see the first.
+ *
+ * This is structurally almost identical to cbf.c's own private seen[]
+ * set (same TTL'd recent-pair-history shape) - kept separate rather
+ * than unified, on purpose: cbf.c's version is tangled up with its own
+ * forward-disarm logic (relayed_closer_to_dst), and that code is
+ * already field-validated on real hardware, so this doesn't touch it.
+ * The duplication is real; revisit if it ever becomes a maintenance
+ * problem.
+ */
+#ifndef RX_DEDUP_H_
+#define RX_DEDUP_H_
+
+#include <stdint.h>
+#include <stdbool.h>
+
+/* Must be called once at boot. */
+void rx_dedup_init(void);
+
+/*
+ * Returns true (a duplicate - do not dispatch) if (src, seq) was already
+ * seen within the dedup window; false (and records it) the first time.
+ */
+bool rx_dedup_check_and_add(uint8_t src, uint16_t seq);
+
+#endif /* RX_DEDUP_H_ */

+ 30 - 0
src/log_ctx.h

@@ -0,0 +1,30 @@
+/*
+ * Compact, human-grokable context dumps for debug logs.
+ *
+ * Call sites were drifting toward one bespoke hand-formatted string per
+ * log line; this just fixes the convention instead of inventing a new
+ * mechanism - space-separated "name=value" fields, no padding, caller's
+ * own printf fmt/args same as a direct LOG_DBG() call. That keeps dumps
+ * grep-able across modules ("grep 'uncertainty_ms=' " works everywhere)
+ * without a format-string-generating macro that would hide the types
+ * from the compiler's own printf checking.
+ *
+ * Usage:
+ *   LOG_CTX_DBG("offer", "source=%d epoch_ms=%llu uncertainty_ms=%u",
+ *               source, epoch_ms, uncertainty_ms);
+ *
+ * Expands in the including TU, so it binds to whatever module that TU
+ * registered via LOG_MODULE_REGISTER() - same as calling LOG_DBG()
+ * there directly. Never call this (or LOG_DBG) while holding a
+ * spinlock: logging - even in CONFIG_LOG_MODE_IMMEDIATE's synchronous
+ * path - does blocking backend I/O, which a spin lock's IRQ-disabled
+ * critical section cannot afford. Capture the values under lock, then
+ * log them after unlocking (see time_ctx.c's time_ctx_offer() for the
+ * pattern).
+ */
+#ifndef LOG_CTX_H_
+#define LOG_CTX_H_
+
+#define LOG_CTX_DBG(label, fmt, ...) LOG_DBG(label ": " fmt, ##__VA_ARGS__)
+
+#endif /* LOG_CTX_H_ */

+ 43 - 0
src/log_levels.h

@@ -0,0 +1,43 @@
+/*
+ * Single place to pick each module's build-time log level, so
+ * suppressing/emitting one module's debug logging is a one-line edit
+ * here instead of hunting down its LOG_MODULE_REGISTER() call - and so
+ * the choices for every module are visible together instead of
+ * scattered across every .c file.
+ *
+ * LOG_MODULE_REGISTER()'s level argument is a *compile-time* cap: at
+ * LOG_LEVEL_INF, LOG_DBG() calls in that file are compiled out
+ * entirely (zero cost, but gone for good until rebuilt+reflashed - no
+ * runtime shell toggle here, by design, since the relay node variants
+ * don't carry CONFIG_SHELL). A component still in active bring-up
+ * (richer LOG_DBG() tracing wanted day to day) gets LOG_LEVEL_DBG;
+ * once it's matured, flip it back to LOG_LEVEL_INF here to drop the
+ * debug noise (and the flash it costs) without touching its source.
+ */
+#ifndef LOG_LEVELS_H_
+#define LOG_LEVELS_H_
+
+#include <zephyr/logging/log.h>
+
+#define LOG_LEVEL_MAIN          LOG_LEVEL_INF
+#define LOG_LEVEL_NET_BASE      LOG_LEVEL_INF
+
+#define LOG_LEVEL_BEACON_CTX    LOG_LEVEL_INF
+#define LOG_LEVEL_BEACON_FRAME  LOG_LEVEL_INF
+#define LOG_LEVEL_BEACON_RX     LOG_LEVEL_INF
+#define LOG_LEVEL_CAD_RX        LOG_LEVEL_DBG
+#define LOG_LEVEL_CBF           LOG_LEVEL_INF
+#define LOG_LEVEL_DISCOVERY_RX  LOG_LEVEL_INF
+#define LOG_LEVEL_FRAME         LOG_LEVEL_INF
+#define LOG_LEVEL_GROUP_MAC     LOG_LEVEL_INF
+#define LOG_LEVEL_IDENT         LOG_LEVEL_INF
+#define LOG_LEVEL_IDENT_PRIVKEY LOG_LEVEL_INF
+#define LOG_LEVEL_PAYLOAD_RX    LOG_LEVEL_INF
+#define LOG_LEVEL_PEER_STATS    LOG_LEVEL_INF
+#define LOG_LEVEL_PKT_LOG       LOG_LEVEL_INF  /* raw RX/TX hex dumps, see pkt_log.c */
+#define LOG_LEVEL_PUBKEY_TABLE  LOG_LEVEL_INF
+#define LOG_LEVEL_RX_DEDUP      LOG_LEVEL_INF
+#define LOG_LEVEL_TIME_CTX      LOG_LEVEL_INF
+#define LOG_LEVEL_TX_QUEUE      LOG_LEVEL_INF
+
+#endif /* LOG_LEVELS_H_ */

+ 571 - 0
src/main.c

@@ -0,0 +1,571 @@
+#include <string.h>
+
+#include <zephyr/device.h>
+#include <zephyr/drivers/lora.h>
+#include <zephyr/drivers/lora/sx126x.h>
+#include <zephyr/fs/fs.h>
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/atomic.h>
+#include <zephyr/sys/byteorder.h>
+#include <zephyr/sys/util.h>
+
+#include "beacon/beacon_ctx.h"
+#include "frame/beacon_frame.h"
+#include "beacon/beacon_rx.h"
+#include "beacon/beacon_work.h"
+#include "relay/cad_rx.h"
+#include "relay/cbf.h"
+#include "rx/discovery_rx.h"
+#include "frame/frame.h"
+#include "crypto/group_mac.h"
+#include "crypto/ident.h"
+#include "crypto/ident_privkey.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+#include "frame/msg_frame.h"
+#include "rx/payload_rx.h"
+#include "rx/payload_work.h"
+#include "net/peer_stats.h"
+#include "frame/pkt_log.h"
+#include "crypto/pubkey_table.h"
+#include "relay/relay_work.h"
+#include "frame/rx_ctx.h"
+#include "frame/rx_dedup.h"
+#include "net/time_ctx.h"
+#include "relay/topology.h"
+#include "relay/tx_queue.h"
+
+#ifdef CONFIG_APP_BASE_STATION
+#include "net/net_base.h"
+#endif
+
+LOG_MODULE_REGISTER(relay_zephyr, LOG_LEVEL_MAIN);
+
+#define LORA_NODE DT_ALIAS(lora0)
+BUILD_ASSERT(DT_NODE_HAS_STATUS_OKAY(LORA_NODE), "lora0 alias missing/disabled in devicetree");
+
+/*
+ * Radio bench/field toggle: flip LORA_BENCH_TEST to 0 before range testing.
+ * Bench (1): min SX1262 PA power and fastest SF, to minimize on-air time
+ *   and radiated power while units sit wired together on the desk.
+ * Field (0): full PA power and a slower SF for range.
+ */
+#define LORA_BENCH_TEST 1
+
+#if LORA_BENCH_TEST
+#define LORA_TX_POWER_DBM (-9)
+#define LORA_SF SF_7
+#else
+#define LORA_TX_POWER_DBM 8
+#define LORA_SF SF_10
+#endif
+
+/*
+ * RX mode toggle: 0 (default) keeps the shipped always-RX baseline
+ * (continuous lora_recv_async()). 1 swaps in cad_rx.c's CAD-poll/capture
+ * cycle instead - much lower average RX power on bench power, since the
+ * radio only opens a full RX window after CAD actually sees something,
+ * but not yet validated in the field (false-negative rate at range,
+ * CAD's own power draw vs continuous RX's, capture-window drops under
+ * back-to-back traffic).
+ */
+#define RX_MODE_CAD_TRIGGER 0
+
+/* Override with `west build ... -- -DEXTRA_CFLAGS=-DNODE_ID=1` per node. */
+#ifndef NODE_ID
+#define NODE_ID 0
+#endif
+
+/*
+ * Bench-test aid: pretend this node can't hear a given TX id, even though
+ * the radios are direct-connected and would otherwise hear everything.
+ * Override with `-DEXTRA_CFLAGS=-DSIM_DROP_TX_ID=<id>` per node. -1 never
+ * matches a real (uint8_t) tx id, so the filter is a no-op by default.
+ */
+#ifndef SIM_DROP_TX_ID
+#define SIM_DROP_TX_ID -1
+#endif
+
+static const struct device *lora_dev;
+
+static uint16_t tx_msg_seq;
+
+#define TEST_CURRENT_TABLE_ID 0x001
+
+static uint32_t alt_table_ids[BEACON_MAX_KV_PAIRS];
+static size_t alt_table_id_count;
+
+static uint8_t central_privkey[IDENT_PRIVKEY_LEN];
+static bool central_privkey_ok;
+
+/*
+ * on_rx() itself runs synchronously inside the (unpatched) sx126x
+ * driver's irq_work, on the system workqueue - so it has to stay
+ * fast and shallow.
+ */
+static struct {
+	struct k_work work;
+	uint8_t buf[FRAME_MAX_LEN];
+	uint16_t len;
+	int16_t rssi;
+	int8_t snr;
+	uint64_t rx_done_ns;
+	bool rx_done_ns_valid;
+	atomic_t busy;
+} rx_work;
+
+static void rx_work_handler(struct k_work *work)
+{
+	ARG_UNUSED(work);
+
+	/* Log every frame heard on the channel, regardless of what the
+	 * decode/MAC/CBF checks below decide to do with it.
+	 */
+	pkt_log_raw("RX raw", rx_work.buf, rx_work.len);
+
+	struct frame_hdr hdr;
+	const uint8_t *payload;
+	size_t payload_len;
+
+	int ret = frame_decode_hdr(rx_work.buf, rx_work.len, &hdr, &payload, &payload_len);
+
+	if (ret < 0) {
+		LOG_WRN("RX frame rejected (malformed, err=%d, len=%u) rssi=%d snr=%d",
+			ret, rx_work.len, rx_work.rssi, rx_work.snr);
+		atomic_set(&rx_work.busy, 0);
+		return;
+	}
+
+	bool mac_ok = frame_group_mac_ok(rx_work.buf, rx_work.len);
+
+	/*
+	 * Every PTYPE except Discovery is rejected outright on a bad group
+	 * MAC
+	 */
+	if (!mac_ok && hdr.ptype != PTYPE_DISCOVERY) {
+		LOG_WRN("RX frame rejected (bad group MAC, ptype=%u, len=%u) rssi=%d snr=%d",
+			hdr.ptype, rx_work.len, rx_work.rssi, rx_work.snr);
+		atomic_set(&rx_work.busy, 0);
+		return;
+	}
+
+	LOG_INF("RX frame: ver=%u dir=%u relay=%u ptype=%u TX=%u SRC=%u DST=%u SEQ=%u payload_len=%zu",
+		hdr.ver, hdr.dir, hdr.relay, hdr.ptype, hdr.tx, hdr.src, hdr.dst, hdr.seq,
+		payload_len);
+
+	if ((int)hdr.tx == SIM_DROP_TX_ID) {
+		LOG_INF("RX frame from TX=%u dropped (SIM_DROP_TX_ID)", hdr.tx);
+		atomic_set(&rx_work.busy, 0);
+		return;
+	}
+
+	/* Radio-link stats for whichever node this hop's TX is
+	 */
+	peer_stats_record(hdr.tx, rx_work.rssi, rx_work.snr, rx_work.rx_done_ns,
+			   rx_work.rx_done_ns_valid);
+
+	/* Someone relaying a message we originated - the overhear-the-relay ack
+	 */
+	if (hdr.src == NODE_ID && hdr.tx != NODE_ID) {
+		peer_stats_record_relayed_for_us(hdr.tx, rx_work.rx_done_ns,
+						   rx_work.rx_done_ns_valid);
+	}
+
+	/*
+	 * Gate RX-handler dispatch only
+	 */
+	bool is_self_src = (hdr.src == NODE_ID);
+	bool is_dup = is_self_src || rx_dedup_check_and_add(hdr.src, hdr.seq);
+
+	LOG_CTX_DBG("rx/dispatch_gate", "src=%u seq=%u is_self_src=%d is_dup=%d", hdr.src, hdr.seq,
+		    is_self_src, is_dup);
+
+	if (is_dup) {
+		/* Already captured above
+		 */
+	} else {
+		/* Dispatch by PTYPE to its own queue
+		 */
+		LOG_CTX_DBG("rx/dispatch", "ptype=%u payload_len=%zu", hdr.ptype, payload_len);
+
+		/* Traffic counters keyed by hdr.src
+		 */
+		if (hdr.dst == NODE_ID) {
+			peer_stats_record_rx_unicast(hdr.src, rx_work.rx_done_ns,
+						       rx_work.rx_done_ns_valid);
+		} else if (hdr.dst == FRAME_DST_ALL) {
+			peer_stats_record_rx_broadcast(hdr.src, rx_work.rx_done_ns,
+							 rx_work.rx_done_ns_valid);
+		}
+
+		/*
+		 * One rx_ctx per dispatched packet, bundling
+		 * everything a submit_*() call might want
+		 */
+		struct rx_ctx ctx = {
+			.hdr = &hdr,
+			.payload = payload,
+			.payload_len = payload_len,
+			.mac_ok = mac_ok,
+			/* sx126x_irq_timestamp_get()'s ns is already an
+			 * absolute, k_uptime_get()-comparable value (resolved
+			 * in the driver itself - see zephyr-patches/0001's
+			 * rewritten commit message), so this is a plain unit
+			 * conversion, not a reconciliation.
+			 */
+			.rx_done_uptime_ms = rx_work.rx_done_ns_valid
+						     ? (int64_t)(rx_work.rx_done_ns / 1000000ULL)
+						     : k_uptime_get(),
+			.rx_done_valid = rx_work.rx_done_ns_valid,
+			.bctx = beacon_ctx_acquire(),
+		};
+
+		switch (hdr.ptype) {
+		case PTYPE_BEACON:
+			beacon_rx_submit(&ctx);
+			break;
+		case PTYPE_DISCOVERY:
+			discovery_rx_submit(&ctx);
+			break;
+		case PTYPE_MSG:
+			payload_rx_submit_msg(&ctx);
+			break;
+		case PTYPE_DATA:
+			payload_rx_submit_data(&ctx);
+			break;
+		case PTYPE_SECRET:
+			payload_rx_submit_secret(&ctx);
+			break;
+		default:
+			LOG_WRN("RX frame: unrecognized ptype=%u", hdr.ptype);
+			beacon_ctx_release(ctx.bctx);
+			break;
+		}
+	}
+
+	cbf_on_rx(&hdr, rx_work.buf, rx_work.len);
+
+	atomic_set(&rx_work.busy, 0);
+}
+
+static void on_rx(const struct device *dev, uint8_t *data, uint16_t size,
+		   int16_t rssi, int8_t snr, void *user_data)
+{
+	ARG_UNUSED(user_data);
+
+	if (size > sizeof(rx_work.buf)) {
+		LOG_ERR("RX frame too large to defer (%u > %zu)", size, sizeof(rx_work.buf));
+		return;
+	}
+
+	if (!atomic_cas(&rx_work.busy, 0, 1)) {
+		LOG_WRN("RX deferred work still busy, dropping frame (%u bytes)", size);
+		return;
+	}
+
+	/*
+	 * Peek the RX_DONE IRQ timestamp
+	 */
+	rx_work.rx_done_ns_valid =
+		sx126x_irq_timestamp_get(dev, SX126X_IRQ_TS_RX_DONE, &rx_work.rx_done_ns);
+
+	memcpy(rx_work.buf, data, size);
+	rx_work.len = size;
+	rx_work.rssi = rssi;
+	rx_work.snr = snr;
+
+	k_work_submit_to_queue(&relay_work_q, &rx_work.work);
+}
+
+#if !RX_MODE_CAD_TRIGGER
+/* tx_queue's rx_pause/rx_resume for the continuous-RX baseline
+ */
+static void rx_pause(void)
+{
+	lora_recv_async(lora_dev, NULL, NULL);
+}
+
+static void rx_resume(void)
+{
+	int ret = lora_recv_async(lora_dev, on_rx, NULL);
+
+	if (ret < 0) {
+		LOG_ERR("lora_recv_async restart failed: %d", ret);
+	}
+}
+#endif
+
+static uint64_t beacon_timestamp_ms(void)
+{
+	if (time_ctx_source() != TIME_SOURCE_NONE) {
+		return (uint64_t)time_ctx_now_ms() - BEACON_EPOCH_UNIX_MS;
+	}
+	return 0;
+}
+
+static void send_beacon(void)
+{
+	uint64_t timestamp_ms = beacon_timestamp_ms();
+
+	/* current_table_id comes from beacon_ctx
+	 */
+	uint32_t current_table_id = beacon_ctx_table_id();
+	uint16_t kv_keys[BEACON_MAX_KV_PAIRS];
+	uint32_t kv_values[BEACON_MAX_KV_PAIRS];
+	size_t kv_count = 0;
+
+	if (current_table_id != PUBKEY_TABLE_ID_NONE) {
+		kv_keys[kv_count] = BEACON_KEY_TABLE_CURRENT;
+		kv_values[kv_count] = current_table_id;
+		kv_count++;
+	}
+	for (size_t i = 0; i < alt_table_id_count; i++) {
+		kv_keys[kv_count] = BEACON_KEY_TABLE_ALT;
+		kv_values[kv_count] = alt_table_ids[i];
+		kv_count++;
+	}
+
+	LOG_CTX_DBG("send_beacon", "timestamp_ms=%llu current_table_id=0x%03x kv_count=%zu",
+		    timestamp_ms, current_table_id, kv_count);
+
+	if (!central_privkey_ok) {
+		LOG_ERR("No central privkey loaded - skipping Beacon (no unsigned fallback)");
+		return;
+	}
+
+	uint8_t payload[BEACON_PAYLOAD_MAX_LEN];
+	size_t signed_len = beacon_frame_encode(payload, BEACON_CADENCE_MS, timestamp_ms,
+						 kv_keys, kv_values, kv_count);
+
+	if (ident_sign(payload, signed_len, central_privkey, &payload[signed_len]) < 0) {
+		LOG_ERR("ident_sign failed - skipping Beacon");
+		return;
+	}
+
+	size_t payload_len = signed_len + BEACON_SIG_LEN;
+
+	struct frame_hdr hdr = {
+		.ver = 0,
+		.dir = FRAME_DIR_DOWNSTREAM,
+		.relay = FRAME_RELAY_REQUESTED,
+		.ptype = PTYPE_BEACON,
+		.tx = NODE_ID,
+		.src = NODE_ID,
+		.dst = FRAME_DST_ALL,
+		.seq = tx_msg_seq++,
+	};
+
+	uint8_t buf[FRAME_HDR_LEN + sizeof(payload) + GROUP_MAC_LEN];
+	int len = frame_encode(buf, sizeof(buf), &hdr, payload, payload_len);
+
+	if (len < 0) {
+		LOG_ERR("Beacon encode failed: %d", len);
+		return;
+	}
+
+	int ret = tx_queue_submit(buf, (size_t)len, NULL, NULL);
+
+	if (ret < 0) {
+		LOG_ERR("Beacon tx_queue_submit failed: %d", ret);
+	}
+}
+
+static void beacon_work_handler(struct k_work *work)
+{
+	struct k_work_delayable *dwork = k_work_delayable_from_work(work);
+
+	send_beacon();
+	k_work_reschedule_for_queue(&relay_work_q, dwork, K_MSEC(BEACON_CADENCE_MS));
+}
+
+static K_WORK_DELAYABLE_DEFINE(beacon_work, beacon_work_handler);
+
+/* Test traffic
+ */
+#define MSG_CADENCE_MS 30000
+#define PING_TEST_DST  1 /* node 2 (the only scheduled sender) pings node 1 */
+
+static uint16_t ping_nonce;
+
+static void send_ping(uint8_t dst)
+{
+	uint8_t nonce[MSG_PING_DATA_LEN];
+
+	sys_put_be16(ping_nonce++, nonce);
+
+	uint8_t payload[MSG_SUBTYPE_HDR_LEN + MSG_PING_DATA_LEN];
+	size_t payload_len = msg_frame_encode(payload, MSG_SUBTYPE_PING, nonce, sizeof(nonce));
+
+	struct frame_hdr hdr = {
+		.ver = 0,
+		.dir = topology_dir(NODE_ID, dst),
+		.relay = FRAME_RELAY_REQUESTED,
+		.ptype = PTYPE_MSG,
+		.tx = NODE_ID,
+		.src = NODE_ID,
+		.dst = dst,
+		.seq = tx_msg_seq++,
+	};
+
+	uint8_t buf[FRAME_HDR_LEN + sizeof(payload) + GROUP_MAC_LEN];
+	int len = frame_encode(buf, sizeof(buf), &hdr, payload, payload_len);
+
+	if (len < 0) {
+		LOG_ERR("Ping encode failed: %d", len);
+		return;
+	}
+
+	int ret = tx_queue_submit(buf, (size_t)len, NULL, NULL);
+
+	if (ret < 0) {
+		LOG_ERR("Ping tx_queue_submit failed: %d", ret);
+	}
+}
+
+static void msg_work_handler(struct k_work *work)
+{
+	struct k_work_delayable *dwork = k_work_delayable_from_work(work);
+
+	send_ping(PING_TEST_DST);
+	k_work_reschedule_for_queue(&relay_work_q, dwork, K_MSEC(MSG_CADENCE_MS));
+}
+
+static K_WORK_DELAYABLE_DEFINE(msg_work, msg_work_handler);
+
+int main(void)
+{
+	LOG_INF("Build: %s (NODE_ID=%d)", APP_BUILD_ID, NODE_ID);
+
+	/* Check lfs mount
+	 */
+	struct fs_statvfs lfs_stat;
+	int lfs_ret = fs_statvfs(PUBKEY_TABLE_MOUNT_POINT, &lfs_stat);
+
+	if (lfs_ret < 0) {
+		LOG_ERR("littlefs not mounted at %s: %d", PUBKEY_TABLE_MOUNT_POINT, lfs_ret);
+	} else {
+		LOG_INF("littlefs mounted at %s", PUBKEY_TABLE_MOUNT_POINT);
+		LOG_CTX_DBG("littlefs", "mount_point=%s blocks_free=%lu blocks_total=%lu "
+			    "block_size=%lu", PUBKEY_TABLE_MOUNT_POINT,
+			    (unsigned long)lfs_stat.f_bfree, (unsigned long)lfs_stat.f_blocks,
+			    (unsigned long)lfs_stat.f_frsize);
+	}
+
+	/* Needed before pubkey_table_load(), which calls ident_verify()
+	 * against base_ident_pubkey (see ident.h/pubkey_table.h).
+	 */
+	if (ident_init() < 0) {
+		LOG_ERR("ident_init failed");
+		return 0;
+	}
+
+	/* Only central signs Beacon (send_beacon() checks central_privkey_ok,
+	 * never sends unsigned) - no other node needs its own privkey loaded
+	 * yet, even though every node is seeded with one.
+	 */
+	if (NODE_ID == FRAME_SRC_CENTRAL) {
+		int pk_ret = ident_privkey_load(central_privkey);
+
+		if (pk_ret < 0) {
+			LOG_ERR("ident_privkey_load failed: %d - Beacon will not be sent", pk_ret);
+		} else {
+			central_privkey_ok = true;
+		}
+	}
+
+	/* Before net_base_start(): NTP sync (one of time_ctx's callers) can
+	 * fire as soon as WiFi comes up.
+	 */
+	time_ctx_init();
+
+#ifdef CONFIG_APP_BASE_STATION
+	net_base_start();
+#endif
+
+	relay_work_q_start();
+	k_work_init(&rx_work.work, rx_work_handler);
+	rx_dedup_init();
+	peer_stats_init();
+
+	beacon_work_q_start();
+	beacon_ctx_init();
+	/* Test-only, see TEST_CURRENT_TABLE_ID above - the one table this
+	 * node trusts until the first beacon it verifies against it
+	 * advertises a different one (beacon_ctx_commit(), beacon_rx.c).
+	 * A real beacon state machine/loader still doesn't exist.
+	 */
+	beacon_ctx_bootstrap_table(TEST_CURRENT_TABLE_ID);
+	beacon_rx_init();
+	discovery_rx_init();
+
+	payload_work_q_start();
+	payload_rx_init();
+
+	lora_dev = DEVICE_DT_GET(LORA_NODE);
+
+	if (!device_is_ready(lora_dev)) {
+		LOG_ERR("%s not ready", lora_dev->name);
+		return 0;
+	}
+
+	if (group_mac_init() < 0) {
+		LOG_ERR("group_mac_init failed");
+		return 0;
+	}
+
+	/* Matches common/lora-radio.yaml: 915 MHz, BW500/CR4.6. PA power and
+	 * SF are set by the LORA_BENCH_TEST block above.
+	 */
+	struct lora_modem_config config = {
+		.frequency = 915000000,
+		.bandwidth = BW_500_KHZ,
+		.datarate = LORA_SF,
+		.coding_rate = CR_4_6,
+		.preamble_len = 8,
+		.sync_word = 0x2C,
+		.tx_power = LORA_TX_POWER_DBM,
+	};
+
+	int ret = lora_config(lora_dev, &config);
+
+	if (ret < 0) {
+		LOG_ERR("lora_config failed: %d", ret);
+		return 0;
+	}
+
+	cbf_init(NODE_ID);
+
+#if RX_MODE_CAD_TRIGGER
+	cad_rx_start(lora_dev, on_rx, NULL);
+#else
+	ret = lora_recv_async(lora_dev, on_rx, NULL);
+	if (ret < 0) {
+		LOG_ERR("lora_recv_async failed: %d", ret);
+		return 0;
+	}
+#endif
+
+	/* After the first RX arm above - tx_queue owns pausing/resuming RX
+	 * around every TX from here on (see tx_queue.h), through whichever
+	 * mode's pause/resume pair was just armed.
+	 */
+#if RX_MODE_CAD_TRIGGER
+	tx_queue_start(lora_dev, cad_rx_pause, cad_rx_resume);
+#else
+	tx_queue_start(lora_dev, rx_pause, rx_resume);
+#endif
+
+	LOG_INF("Node %d up (ordinal=%u, max_ordinal=%u)", NODE_ID, topology_ordinal(NODE_ID),
+		TOPOLOGY_MAX_ORDINAL);
+
+	if (NODE_ID == FRAME_SRC_CENTRAL) {
+		k_work_schedule_for_queue(&relay_work_q, &beacon_work, K_NO_WAIT);
+	} else if (NODE_ID == 2) {
+		/* Only node 2 originates scheduled Msg traffic
+		 */
+		k_work_schedule_for_queue(&relay_work_q, &msg_work, K_NO_WAIT);
+	}
+
+	return 0;
+}

+ 230 - 0
src/net/net_base.c

@@ -0,0 +1,230 @@
+#include "log_ctx.h"
+#include "log_levels.h"
+#include "net/net_base.h"
+#include "net/time_ctx.h"
+
+#include <zephyr/dfu/mcuboot.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/net/net_event.h>
+#include <zephyr/net/net_if.h>
+#include <zephyr/net/net_mgmt.h>
+#include <zephyr/net/sntp.h>
+#include <zephyr/net/wifi_mgmt.h>
+
+LOG_MODULE_REGISTER(net_base, LOG_LEVEL_NET_BASE);
+
+/* A single NET_REQUEST_WIFI_CONNECT_STORED fired from main() can fail
+ * ("Connection request failed") because the WiFi stack isn't actually
+ * ready for it that early in boot, even though the request call itself
+ * returns success. Without a retry, the base station would come up on
+ * USB power with no network and nothing short of console access to fix
+ * it - unacceptable for an unattended field unit. So: keep retrying on
+ * every connect failure/disconnect, with a capped backoff, forever. The
+ * alternative (giving up) means a box in the field silently drops off
+ * the network until someone drives out to it with a USB cable.
+ */
+#define RETRY_BASE_DELAY_SEC 5
+#define RETRY_MAX_DELAY_SEC  60
+
+/* This WiFi retry work gets its own queue rather than sharing
+ * relay_work_q with the LoRa side (beacon TX, CBF relay forwards). Both
+ * radios run on this one SPI-adjacent CPU; a WiFi reconnect (scan +
+ * wpa_supplicant handshake, can run tens of ms) queued behind - or
+ * ahead of - a CBF relay forward would skew its deliberately-timed
+ * ~200-250ms window, and vice versa. Separate queues means the
+ * scheduler interleaves them instead of one radio's work serializing
+ * behind the other's.
+ *
+ * Sized like relay_work_q's own stack: CONFIG_SHELL_STACK_SIZE needed
+ * 6144 to survive `wifi cred add`'s PSA-crypto/wpa_supplicant call
+ * depth without CONFIG_STACK_SENTINEL tripping (see overlay-base.conf);
+ * connect-stored runs the same depth.
+ */
+#define NET_WORK_STACK_SIZE 6144
+#define NET_WORK_PRIORITY   -1
+
+static K_THREAD_STACK_DEFINE(net_work_stack, NET_WORK_STACK_SIZE);
+static struct k_work_q net_work_q;
+
+static struct net_mgmt_event_callback wifi_cb;
+static struct net_if *wifi_iface;
+static struct k_work_delayable connect_retry_work;
+static unsigned int retry_count;
+
+/* NTP time sync - this unit has no RTC/GPS, so SYS_CLOCK_REALTIME starts
+ * at the epoch and only ever advances by uptime until this (or any other
+ * accepted time_ctx_offer()) lands. Without this, every log/syslog
+ * timestamp reads out near 1970, which a receiver can misfile or just
+ * never show in a "recent" view. CONFIG_LOG_TIMESTAMP_USE_REALTIME
+ * (overlay-base.conf) already points the log subsystem at this same
+ * kernel clock, so disciplining it via time_ctx_offer() is the only
+ * thing this file needs to do - no custom log timestamp function. A
+ * base station with GPS or other precision timing hardware is just
+ * another time_ctx_offer() caller with a tighter uncertainty_ms, same
+ * clock either way (see time_ctx.h).
+ */
+#define NTP_SERVER_ADDR         "172.22.2.2"
+#define NTP_TIMEOUT_MS          5000
+#define NTP_RETRY_DELAY_SEC     30
+#define NTP_RESYNC_INTERVAL_SEC (60 * 60)
+
+static struct k_work_delayable ntp_sync_work;
+
+static void try_connect_stored(void)
+{
+	int ret = net_mgmt(NET_REQUEST_WIFI_CONNECT_STORED, wifi_iface, NULL, 0);
+
+	LOG_CTX_DBG("connect_stored", "ret=%d", ret);
+
+	if (ret < 0) {
+		LOG_WRN("WiFi connect-stored failed (err=%d) - provision credentials "
+			"with `wifi cred add` over the UART console, then `wifi cred "
+			"auto_connect`", ret);
+	}
+}
+
+static void connect_retry_work_handler(struct k_work *work)
+{
+	try_connect_stored();
+}
+
+static void ntp_sync_work_handler(struct k_work *work)
+{
+	struct sntp_time ts;
+	int ret = sntp_simple(NTP_SERVER_ADDR, NTP_TIMEOUT_MS, &ts);
+
+	LOG_CTX_DBG("ntp_sync", "ret=%d", ret);
+
+	if (ret == 0) {
+		uint64_t epoch_ms = (uint64_t)ts.seconds * 1000 +
+				    (((uint64_t)ts.fraction * 1000) >> 32);
+
+		/* CONFIG_SNTP_UNCERTAINTY (overlay-base.conf) makes sntp_simple()
+		 * compute a real uncertainty from measured round-trip delay plus
+		 * the server's own NTP root_delay/root_dispersion/precision
+		 * fields (see sntp.c) - not a guess. Falls back to a documented
+		 * placeholder only if that Kconfig ever gets turned off.
+		 */
+#if defined(CONFIG_SNTP_UNCERTAINTY)
+		uint32_t uncertainty_ms = ts.uncertainty_us / 1000;
+#else
+		uint32_t uncertainty_ms = 500; /* placeholder - enable CONFIG_SNTP_UNCERTAINTY instead */
+#endif
+
+		time_ctx_offer(TIME_SOURCE_NTP, epoch_ms, uncertainty_ms);
+
+		LOG_INF("NTP sync ok (server=%s, uncertainty_ms=%u)", NTP_SERVER_ADDR,
+			uncertainty_ms);
+		k_work_reschedule_for_queue(&net_work_q, &ntp_sync_work,
+					   K_SECONDS(NTP_RESYNC_INTERVAL_SEC));
+	} else {
+		LOG_WRN("NTP sync failed (err=%d), retrying in %d s", ret, NTP_RETRY_DELAY_SEC);
+		k_work_reschedule_for_queue(&net_work_q, &ntp_sync_work,
+					   K_SECONDS(NTP_RETRY_DELAY_SEC));
+	}
+}
+
+static void schedule_retry(void)
+{
+	unsigned int delay_sec = RETRY_BASE_DELAY_SEC << retry_count;
+	bool capped = delay_sec > RETRY_MAX_DELAY_SEC || delay_sec == 0;
+
+	if (capped) {
+		delay_sec = RETRY_MAX_DELAY_SEC;
+	} else {
+		retry_count++;
+	}
+
+	/* DBG, not INF: the WARN at the connect-failed/disconnect call site
+	 * already covers the state transition; this is just backoff
+	 * scheduling detail behind it.
+	 */
+	LOG_CTX_DBG("schedule_retry", "retry_count=%u capped=%d delay_sec=%u", retry_count, capped,
+		    delay_sec);
+	/* k_work_reschedule() always targets the system workqueue (see the
+	 * same note in main.c re: the sx126x IRQ work) - its stack is too
+	 * tight for net_mgmt()'s WiFi/wpa_supplicant call depth. Field-
+	 * confirmed: landed on sysworkq once, corrupted its stack outright
+	 * (illegal instruction, not even a clean CONFIG_STACK_SENTINEL trip).
+	 * Targets net_work_q, not relay_work_q, so a WiFi reconnect can't
+	 * race/serialize against LoRa's own scheduled work - see the
+	 * net_work_q comment above. */
+	k_work_reschedule_for_queue(&net_work_q, &connect_retry_work, K_SECONDS(delay_sec));
+}
+
+static void wifi_event_handler(struct net_mgmt_event_callback *cb, uint64_t mgmt_event,
+			       struct net_if *iface)
+{
+	LOG_CTX_DBG("event", "mgmt_event=0x%llx", mgmt_event);
+
+	switch (mgmt_event) {
+	case NET_EVENT_WIFI_CONNECT_RESULT: {
+		const struct wifi_status *status = (const struct wifi_status *)cb->info;
+
+		if (status && status->status == 0) {
+			LOG_INF("WiFi connected");
+			retry_count = 0;
+			/* main()'s "Build:"/"Node N up" lines fire before the net
+			 * log backend is even active (connection-manager-driven,
+			 * see overlay-base.conf) - syslog never sees them.
+			 * Re-emit the identifying info here, now that it will. */
+			LOG_INF("Build: %s (NODE_ID=%d)", APP_BUILD_ID, NODE_ID);
+			k_work_reschedule_for_queue(&net_work_q, &ntp_sync_work, K_NO_WAIT);
+		} else {
+			LOG_WRN("WiFi connect failed (status=%d)", status ? status->status : -1);
+			schedule_retry();
+		}
+		break;
+	}
+	case NET_EVENT_WIFI_DISCONNECT_RESULT:
+		LOG_WRN("WiFi disconnected");
+		schedule_retry();
+		break;
+	default:
+		break;
+	}
+}
+
+static void confirm_image(void)
+{
+	bool already_confirmed = boot_is_img_confirmed();
+
+	LOG_CTX_DBG("confirm_image", "already_confirmed=%d", already_confirmed);
+
+	if (already_confirmed) {
+		return;
+	}
+
+	int ret = boot_write_img_confirmed();
+
+	if (ret < 0) {
+		LOG_ERR("boot_write_img_confirmed failed: %d", ret);
+	} else {
+		LOG_INF("MCUboot image confirmed");
+	}
+}
+
+void net_base_start(void)
+{
+	confirm_image();
+
+	wifi_iface = net_if_get_first_wifi();
+
+	if (!wifi_iface) {
+		LOG_ERR("no WiFi iface found");
+		return;
+	}
+
+	net_mgmt_init_event_callback(&wifi_cb, wifi_event_handler,
+				     NET_EVENT_WIFI_CONNECT_RESULT | NET_EVENT_WIFI_DISCONNECT_RESULT);
+	net_mgmt_add_event_callback(&wifi_cb);
+
+	k_work_queue_start(&net_work_q, net_work_stack, K_THREAD_STACK_SIZEOF(net_work_stack),
+			   NET_WORK_PRIORITY, NULL);
+	k_thread_name_set(k_work_queue_thread_get(&net_work_q), "net_work");
+
+	k_work_init_delayable(&connect_retry_work, connect_retry_work_handler);
+	k_work_init_delayable(&ntp_sync_work, ntp_sync_work_handler);
+
+	try_connect_stored();
+}

+ 33 - 0
src/net/net_base.h

@@ -0,0 +1,33 @@
+/*
+ * Base-station-only networking bring-up (WiFi connect). Compiled in only
+ * when CONFIG_APP_BASE_STATION=y - see overlay-base.conf.
+ */
+#ifndef NET_BASE_H_
+#define NET_BASE_H_
+
+#include <stdbool.h>
+#include <stdint.h>
+
+/*
+ * NTP-synced wall-clock time now lives in time_ctx.h, not here -
+ * net_base.c's ntp_sync_work_handler() is just one of time_ctx's
+ * callers (time_ctx_offer(TIME_SOURCE_NTP, ...)). Use
+ * time_ctx_now_ms()/time_ctx_source() instead of the old
+ * wall_clock_now_ms()/wall_clock_is_synced() this file used to declare.
+ */
+
+/*
+ * Replays NET_REQUEST_WIFI_CONNECT_STORED against the WiFi iface so a
+ * base station reconnects on every boot after credentials have been
+ * provisioned once via the `wifi cred add` shell command (UART console,
+ * before WiFi/telnet exist yet). No-op (just logs) if no credentials are
+ * stored.
+ *
+ * Also confirms the running MCUboot image (boot_write_img_confirmed()) if
+ * it isn't already - reaching this call means the app got far enough to
+ * bring up networking, which is confirmation enough to stop MCUboot from
+ * reverting to the previous slot on the next reboot.
+ */
+void net_base_start(void);
+
+#endif /* NET_BASE_H_ */

+ 221 - 0
src/net/peer_stats.c

@@ -0,0 +1,221 @@
+#include "log_ctx.h"
+#include "log_levels.h"
+#include "net/peer_stats.h"
+#include "psram_bss.h"
+
+#include <string.h>
+
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+
+LOG_MODULE_REGISTER(peer_stats, LOG_LEVEL_PEER_STATS);
+
+/*
+ * alpha = 1/2^PEER_STATS_EWMA_SHIFT. Jacobson's integer SRTT trick
+ * (RFC 6298-style: err = sample - (avg_q >> shift); avg_q += err) -
+ * same O(1) cost as a running sum, no float, no sample buffer; avg_q
+ * carries the fractional part a plain integer average would lose
+ * between updates. 3 (alpha 1/8) is a reasonable "recent trend, not
+ * jumpy on one sample" default - nothing here depends on the exact
+ * value, so it's one easy knob to retune once there's real traffic to
+ * tune it against.
+ */
+#define PEER_STATS_EWMA_SHIFT 3
+
+struct peer_stats_slot {
+	int32_t rssi_ewma_q; /* rssi_mean << PEER_STATS_EWMA_SHIFT */
+	int32_t snr_ewma_q;  /* snr_mean << PEER_STATS_EWMA_SHIFT */
+	uint32_t frame_count;
+	int64_t last_seen_ms;
+	bool valid;
+
+	/* Traffic counters - see peer_stats.h's header comment for which
+	 * header field keys node_id into each one.
+	 */
+	uint32_t relayed_for_us_count;
+	int64_t relayed_for_us_last_ms;
+	uint32_t rx_unicast_count;
+	int64_t rx_unicast_last_ms;
+	uint32_t rx_broadcast_count;
+	int64_t rx_broadcast_last_ms;
+	uint32_t tx_to_count;
+	int64_t tx_to_last_ms;
+};
+
+/* Direct-indexed by node_id (peers[node_id]) over the full address
+ * space - PSRAM-backed (PSRAM_BSS, see psram_bss.h), not internal SRAM,
+ * which is what makes sizing this for every possible node_id instead of
+ * a small find-or-evict table affordable. lock stays in normal BSS:
+ * tiny, and taken on every single RX/TX frame (these record calls run
+ * inline on relay_work_q/tx_queue's own thread), not worth PSRAM's
+ * extra latency.
+ */
+static PSRAM_BSS struct peer_stats_slot peers[PEER_STATS_MAX_PEERS];
+static struct k_spinlock lock;
+
+/* The RX_DONE/TX_DONE IRQ timestamp peek, when the caller got one,
+ * instead of a fresh k_uptime_get() read here - exactly the kind of
+ * consumer sx126x_irq_timestamp_get() exists for: a reading taken as
+ * close to the actual RX/TX event as this app gets, rather than however
+ * much later this deferred work happened to run. Same timebase either
+ * way (k_cycle_get_32()'s, ms vs. this peek's ns), so every *_ms field
+ * in this file stays directly comparable regardless of which source
+ * produced it.
+ */
+static int64_t resolve_now_ms(uint64_t done_ns, bool done_ns_valid)
+{
+	return done_ns_valid ? (int64_t)(done_ns / 1000000ULL) : k_uptime_get();
+}
+
+void peer_stats_init(void)
+{
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	memset(peers, 0, sizeof(peers));
+
+	k_spin_unlock(&lock, key);
+
+	/* INF, not DBG: a lifecycle event (the whole table resetting), not
+	 * routine per-frame counting - see peer_stats_record()'s own
+	 * comment on that split.
+	 */
+	LOG_INF("peer stats table cleared");
+}
+
+void peer_stats_record(uint8_t node_id, int16_t rssi, int8_t snr, uint64_t rx_done_ns,
+			bool rx_done_ns_valid)
+{
+	int64_t now_ms = resolve_now_ms(rx_done_ns, rx_done_ns_valid);
+
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	struct peer_stats_slot *peer = &peers[node_id];
+
+	if (!peer->valid) {
+		/* First sample for this peer - seed the EWMA with it exactly
+		 * rather than decaying in from zero, so one sample already
+		 * means something.
+		 */
+		peer->rssi_ewma_q = (int32_t)rssi << PEER_STATS_EWMA_SHIFT;
+		peer->snr_ewma_q = (int32_t)snr << PEER_STATS_EWMA_SHIFT;
+		peer->valid = true;
+	} else {
+		peer->rssi_ewma_q += (int32_t)rssi - (peer->rssi_ewma_q >> PEER_STATS_EWMA_SHIFT);
+		peer->snr_ewma_q += (int32_t)snr - (peer->snr_ewma_q >> PEER_STATS_EWMA_SHIFT);
+	}
+	peer->frame_count++;
+	peer->last_seen_ms = now_ms;
+
+	int32_t rssi_mean = peer->rssi_ewma_q >> PEER_STATS_EWMA_SHIFT;
+	int32_t snr_mean = peer->snr_ewma_q >> PEER_STATS_EWMA_SHIFT;
+	uint32_t frame_count = peer->frame_count;
+
+	k_spin_unlock(&lock, key);
+
+	/* One line per call - sample in, resulting EWMA/count out (not two
+	 * separate "here's the input" / "here's the result" lines - this
+	 * is one logical update). DBG, not INF: routine per-frame
+	 * bookkeeping doesn't warrant log-info, only a lifecycle event like
+	 * peer_stats_init()'s reset does.
+	 */
+	LOG_CTX_DBG("record", "node=%u rssi=%d snr=%d rx_done_ns_valid=%d -> rssi_mean=%d "
+		    "snr_mean=%d frame_count=%u", node_id, rssi, snr, rx_done_ns_valid, rssi_mean,
+		    snr_mean, frame_count);
+}
+
+/* Shared by the four traffic counters below - bump count/last_ms for
+ * peers[node_id] under lock, one DBG line per call (routine counting,
+ * same "stays quiet by default" reasoning as peer_stats_record()
+ * above).
+ */
+static void bump_counter(uint8_t node_id, uint32_t *count, int64_t *last_ms, int64_t now_ms,
+			  const char *label)
+{
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	(*count)++;
+	*last_ms = now_ms;
+
+	uint32_t new_count = *count;
+
+	k_spin_unlock(&lock, key);
+
+	LOG_CTX_DBG("bump", "%s node=%u count=%u", label, node_id, new_count);
+}
+
+void peer_stats_record_relayed_for_us(uint8_t relayed_by, uint64_t rx_done_ns,
+					bool rx_done_ns_valid)
+{
+	int64_t now_ms = resolve_now_ms(rx_done_ns, rx_done_ns_valid);
+
+	bump_counter(relayed_by, &peers[relayed_by].relayed_for_us_count,
+		     &peers[relayed_by].relayed_for_us_last_ms, now_ms, "relayed_for_us");
+}
+
+void peer_stats_record_rx_unicast(uint8_t src, uint64_t rx_done_ns, bool rx_done_ns_valid)
+{
+	int64_t now_ms = resolve_now_ms(rx_done_ns, rx_done_ns_valid);
+
+	bump_counter(src, &peers[src].rx_unicast_count, &peers[src].rx_unicast_last_ms, now_ms,
+		     "rx_unicast");
+}
+
+void peer_stats_record_rx_broadcast(uint8_t src, uint64_t rx_done_ns, bool rx_done_ns_valid)
+{
+	int64_t now_ms = resolve_now_ms(rx_done_ns, rx_done_ns_valid);
+
+	bump_counter(src, &peers[src].rx_broadcast_count, &peers[src].rx_broadcast_last_ms, now_ms,
+		     "rx_broadcast");
+}
+
+void peer_stats_record_tx_to(uint8_t dst, uint64_t tx_done_ns, bool tx_done_ns_valid)
+{
+	int64_t now_ms = resolve_now_ms(tx_done_ns, tx_done_ns_valid);
+
+	bump_counter(dst, &peers[dst].tx_to_count, &peers[dst].tx_to_last_ms, now_ms, "tx_to");
+}
+
+/* Whether slot i has anything worth handing out - either the radio-hop
+ * group (valid) or any of the four traffic counters, which can each
+ * start accumulating independently of it and of each other (e.g. we
+ * might transmit to a peer we've never directly heard a radio hop
+ * from).
+ */
+static bool slot_has_data(const struct peer_stats_slot *p)
+{
+	return p->valid || p->relayed_for_us_count || p->rx_unicast_count ||
+	       p->rx_broadcast_count || p->tx_to_count;
+}
+
+size_t peer_stats_snapshot(struct peer_stats_entry *out, size_t max_out)
+{
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	size_t n = 0;
+
+	for (int i = 0; i < PEER_STATS_MAX_PEERS && n < max_out; i++) {
+		if (!slot_has_data(&peers[i])) {
+			continue;
+		}
+
+		out[n++] = (struct peer_stats_entry){
+			.node_id = (uint8_t)i,
+			.rssi_mean = (int16_t)(peers[i].rssi_ewma_q >> PEER_STATS_EWMA_SHIFT),
+			.snr_mean = (int8_t)(peers[i].snr_ewma_q >> PEER_STATS_EWMA_SHIFT),
+			.frame_count = peers[i].frame_count,
+			.last_seen_ms = peers[i].last_seen_ms,
+			.relayed_for_us_count = peers[i].relayed_for_us_count,
+			.relayed_for_us_last_ms = peers[i].relayed_for_us_last_ms,
+			.rx_unicast_count = peers[i].rx_unicast_count,
+			.rx_unicast_last_ms = peers[i].rx_unicast_last_ms,
+			.rx_broadcast_count = peers[i].rx_broadcast_count,
+			.rx_broadcast_last_ms = peers[i].rx_broadcast_last_ms,
+			.tx_to_count = peers[i].tx_to_count,
+			.tx_to_last_ms = peers[i].tx_to_last_ms,
+		};
+	}
+
+	k_spin_unlock(&lock, key);
+
+	return n;
+}

+ 179 - 0
src/net/peer_stats.h

@@ -0,0 +1,179 @@
+/*
+ * Per-node_id stats table - one row per possible node_id, but each row
+ * actually holds several independent groups of fields, each keyed off
+ * that same node_id in a *different role* depending on which header
+ * field named it:
+ *
+ *   - rssi_mean/snr_mean/frame_count/last_seen_ms: node_id as hdr.tx,
+ *     the immediate transmitter of a radio hop we heard - radio-link
+ *     quality for that one hop, independent of the frame's content or
+ *     relay chain. Recorded for every frame that passes the group-MAC/
+ *     Discovery-ident gate (see main.c's rx dispatch) regardless of
+ *     PTYPE, direction, or RX-dedup status - a duplicate frame's radio
+ *     stats for this hop are still real, new information about this
+ *     link. This group is network-*operations* monitoring (is this
+ *     link's signal healthy, is a peer's radio degrading, who's
+ *     actually in range of whom) - deliberately not network-
+ *     *reliability* monitoring. There's no reliable way to count missed
+ *     packets from this group alone: hdr.seq is the originating node's
+ *     own counter, and most frames heard at any given hop are relayed
+ *     traffic from other sources mixed with that node's own, so a gap
+ *     in seq values doesn't mean a clean per-link loss count.
+ *
+ *   - relayed_for_us_count/_last_ms: node_id as hdr.tx, having relayed
+ *     a message *we* originated (the "overhear the relay" ack cbf.c's
+ *     own forwarding already relies on, surfaced as its own signal -
+ *     deliberately not gated by RX dedup: a second relayer echoing the
+ *     same message is a second path confirmation, not a duplicate).
+ *     Tells us which neighbors are actually carrying our traffic, and
+ *     is the basis for inferring which path a message usually takes.
+ *
+ *   - rx_unicast_count/_last_ms, rx_broadcast_count/_last_ms: node_id as
+ *     hdr.src, a logically-new (non-dup, non-self) frame *from* that
+ *     node addressed to us specifically or to everyone, respectively.
+ *
+ *   - tx_to_count/_last_ms: node_id as hdr.dst, a frame *we originated*
+ *     (hdr.src == our own node_id) addressed to that node - CBF relay
+ *     forwards of someone else's traffic deliberately don't count here,
+ *     only self-originated sends, since the whole point is a 1:1
+ *     comparison against that peer's own rx_unicast_count for us (see
+ *     below) - a relay forward still carries the original sender's
+ *     node_id, not ours, so it would never match on the other end
+ *     anyway. A broadcast send lands in the FRAME_DST_ALL (0xFF) slot -
+ *     never a real node_id, so no separate bucket is needed for it.
+ *
+ * These last three groups are deliberately the cross-node half of
+ * deliverability tracking: our tx_to_count for peer X is meant to be
+ * compared against X's own rx_unicast_count for us, once both are
+ * aggregated somewhere that can see both nodes' logs (central, offline)
+ * - nothing on this node infers loss/deliverability by itself from
+ * these counts alone. The radio-link group above stays purely local
+ * ops monitoring, same as always.
+ *
+ * rssi_mean/snr_mean are tracked as an EWMA (exponential moving
+ * average), not a true sliding window: no sample buffer, O(1) per
+ * update, and recent samples naturally dominate without a hard cutoff
+ * where an old sample falls out of a window all at once. RSSI
+ * especially is half-ephemeral (multipath, orientation, whatever's near
+ * the antenna right now) - exact precision over a defined time span was
+ * never the goal, just a trend that isn't jerked around by any single
+ * sample. The four traffic counters above are plain counts - a future
+ * global counter-window reset (not designed yet) will archive and zero
+ * all five of them periodically; only the EWMA/last-seen fields update
+ * on their own without needing that reset.
+ */
+#ifndef PEER_STATS_H_
+#define PEER_STATS_H_
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+/* The full node_id address space (node_id is a uint8_t, see frame.h) -
+ * direct-indexed by node_id (peers[node_id] in peer_stats.c), not a
+ * find-or-evict table, so there's no sizing tradeoff to make and no
+ * eviction policy to get wrong. Affordable because the backing array
+ * lives in PSRAM (PSRAM_BSS, see psram_bss.h), not internal SRAM - see
+ * pubkey_table.h's similar reasoning.
+ */
+#define PEER_STATS_MAX_PEERS 256
+
+/* One peer's stats, as handed out by peer_stats_snapshot() - already
+ * decoded to plain units, not the EWMA's internal fixed-point
+ * representation (see peer_stats.c).
+ */
+struct peer_stats_entry {
+	uint8_t node_id;
+	int16_t rssi_mean; /* EWMA mean RSSI, dBm */
+	int8_t snr_mean;   /* EWMA mean SNR, dB */
+
+	/*
+	 * Lifetime, not windowed - how many samples back the EWMA above,
+	 * i.e. how much to trust it (a peer heard once has a "mean" that's
+	 * just that one sample). An accumulating count like this is the
+	 * kind of thing a future global counter-window reset will archive
+	 * and zero periodically (not designed yet) - unlike rssi_mean/
+	 * snr_mean/last_seen_ms below, which decay/update on their own and
+	 * don't need resetting, only (eventually) garbage-collecting out
+	 * of the table if a peer goes stale enough.
+	 */
+	uint32_t frame_count;
+
+	/*
+	 * ms timebase at the last recorded frame from this peer - the
+	 * RX_DONE IRQ timestamp peek when peer_stats_record() got a valid
+	 * one (same timebase as k_uptime_get(), just finer-grained - see
+	 * peer_stats.c), a plain k_uptime_get() read otherwise. Local
+	 * freshness only (tells this node whether its own data on this
+	 * peer is current or stale/intermittent), not a wall-clock
+	 * timestamp meaningful to compare across nodes. Diff against a
+	 * fresh k_uptime_get() read on this same node.
+	 */
+	int64_t last_seen_ms;
+
+	/* Traffic counters - see this file's header comment for exactly
+	 * which header field (hdr.tx/hdr.src/hdr.dst) keys node_id into
+	 * each group below. Every *_ms field here is the same RX_DONE/
+	 * TX_DONE-peek-or-k_uptime_get()-fallback timestamp last_seen_ms
+	 * above uses, just scoped to that one counter's own last
+	 * occurrence rather than any frame from this node_id at all.
+	 */
+	uint32_t relayed_for_us_count; /* node_id (as hdr.tx) relayed a message we sent */
+	int64_t relayed_for_us_last_ms;
+
+	uint32_t rx_unicast_count; /* frame from node_id (as hdr.src), addressed to us */
+	int64_t rx_unicast_last_ms;
+
+	uint32_t rx_broadcast_count; /* frame from node_id (as hdr.src), addressed to everyone */
+	int64_t rx_broadcast_last_ms;
+
+	uint32_t tx_to_count; /* we originated a frame addressed to node_id (as hdr.dst) */
+	int64_t tx_to_last_ms;
+};
+
+/* Must be called once at boot. */
+void peer_stats_init(void);
+
+/*
+ * Records one RX observation from node_id - updates its RSSI/SNR EWMA,
+ * bumps its frame count, and stamps last_seen_ms. rx_done_ns/
+ * rx_done_ns_valid (the RX_DONE IRQ timestamp peek, see
+ * sx126x_irq_timestamp_get()) become that stamp when valid - a reading
+ * taken as close to the actual RX event as this app gets, rather than
+ * however much later this deferred work happened to run; falls back to
+ * a plain k_uptime_get() read otherwise.
+ */
+void peer_stats_record(uint8_t node_id, int16_t rssi, int8_t snr, uint64_t rx_done_ns,
+			bool rx_done_ns_valid);
+
+/*
+ * Traffic counters - see this file's header comment for what each one
+ * means and which header field keys node_id into it. All four take the
+ * same RX_DONE/TX_DONE IRQ timestamp peek (or its validity flag) as
+ * peer_stats_record() does, for the same reason: a reading taken as
+ * close to the actual RX/TX event as this app gets, falling back to a
+ * plain k_uptime_get() read when the peek didn't land.
+ */
+void peer_stats_record_relayed_for_us(uint8_t relayed_by, uint64_t rx_done_ns,
+					bool rx_done_ns_valid);
+void peer_stats_record_rx_unicast(uint8_t src, uint64_t rx_done_ns, bool rx_done_ns_valid);
+void peer_stats_record_rx_broadcast(uint8_t src, uint64_t rx_done_ns, bool rx_done_ns_valid);
+
+/* dst is hdr.dst - call only for frames this node originated (hdr.src
+ * == our own node_id), never for a CBF relay forward of someone else's
+ * traffic; see tx_to_count's own comment above for why.
+ */
+void peer_stats_record_tx_to(uint8_t dst, uint64_t tx_done_ns, bool tx_done_ns_valid);
+
+/*
+ * Copies every currently-tracked peer's stats into out (caller-sized,
+ * up to max_out entries) and returns the count written. Safe from any
+ * thread/queue - takes a lock for the instant it takes to copy. The
+ * intended reader: periodic aggregation up to central for offline
+ * analysis, and live debugging (e.g. a future shell/telnet command on
+ * the base station) - neither exists yet, this is just the one place
+ * either would read from.
+ */
+size_t peer_stats_snapshot(struct peer_stats_entry *out, size_t max_out);
+
+#endif /* PEER_STATS_H_ */

+ 169 - 0
src/net/time_ctx.c

@@ -0,0 +1,169 @@
+#include "net/time_ctx.h"
+
+#include "frame/beacon_frame.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+
+#include <time.h>
+
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/clock.h>
+LOG_MODULE_REGISTER(time_ctx, LOG_LEVEL_TIME_CTX);
+
+/*
+ * Placeholder uncompensated-crystal drift bound, pending a real spec for
+ * whatever oscillator ends up driving this clock - see project notes.
+ * Used only to grow a held estimate's uncertainty between offers; it
+ * does not affect which offers get accepted in the first place (that's
+ * uncertainty_ms as given by the source, compared as-is).
+ */
+#define TIME_CTX_DRIFT_PPM 50
+
+/*
+ * Resync margin: a new offer must be at least this fraction tighter
+ * than the held estimate's current drift-grown uncertainty to be
+ * accepted - a bare "strictly tighter" comparison isn't enough, because
+ * TIME_CTX_DRIFT_PPM's growth between offers is tiny relative to a
+ * chatty source's own uncertainty (e.g. Beacon re-offering ~15 s every
+ * cadence would otherwise pass "stricter than a few ms more" almost
+ * every single time, re-stepping the clock for no real gain). A
+ * percentage, not a flat ms margin, so it scales across very different
+ * source magnitudes (ms-level NTP, second-level Beacon) without
+ * separate tuning per source. This self-corrects over time even for a
+ * single unchanging source: once enough drift has accumulated that the
+ * held estimate's grown uncertainty clears this margin against a fresh
+ * same-source offer, that offer is accepted again.
+ */
+#define TIME_CTX_RESYNC_IMPROVEMENT_PCT 10
+
+static struct {
+	bool valid;
+	enum time_source source;
+	uint32_t uncertainty_ms;      /* As given by the source at accept time. */
+	int64_t synced_at_uptime_ms;  /* k_uptime_get() at accept time. */
+} current;
+static struct k_spinlock lock;
+
+void time_ctx_init(void)
+{
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	current.valid = false;
+	current.source = TIME_SOURCE_NONE;
+	current.uncertainty_ms = 0;
+	current.synced_at_uptime_ms = 0;
+
+	k_spin_unlock(&lock, key);
+
+	LOG_CTX_DBG("init", "held estimate cleared");
+}
+
+int64_t time_ctx_now_ms(void)
+{
+	struct timespec tp;
+
+	sys_clock_gettime(SYS_CLOCK_REALTIME, &tp);
+
+	return (int64_t)tp.tv_sec * 1000 + tp.tv_nsec / 1000000;
+}
+
+/* Grows base_uncertainty_ms by TIME_CTX_DRIFT_PPM over elapsed_ms. */
+static uint32_t grow_uncertainty(uint32_t base_uncertainty_ms, int64_t elapsed_ms)
+{
+	if (elapsed_ms < 0) {
+		elapsed_ms = 0;
+	}
+
+	uint64_t drift_ms = ((uint64_t)elapsed_ms * TIME_CTX_DRIFT_PPM) / 1000000;
+
+	if (drift_ms > UINT32_MAX - base_uncertainty_ms) {
+		return UINT32_MAX;
+	}
+
+	return base_uncertainty_ms + (uint32_t)drift_ms;
+}
+
+/* current's drift-grown uncertainty right now. Caller must hold lock. */
+static uint32_t current_effective_uncertainty_locked(void)
+{
+	if (!current.valid) {
+		return UINT32_MAX;
+	}
+
+	int64_t elapsed_ms = k_uptime_get() - current.synced_at_uptime_ms;
+
+	return grow_uncertainty(current.uncertainty_ms, elapsed_ms);
+}
+
+bool time_ctx_offer(enum time_source source, uint64_t epoch_ms, uint32_t uncertainty_ms)
+{
+	bool implausible = epoch_ms < BEACON_EPOCH_UNIX_MS ||
+			    (uint64_t)time_ctx_now_ms() < BEACON_EPOCH_UNIX_MS;
+
+	LOG_CTX_DBG("offer", "source=%d epoch_ms=%llu uncertainty_ms=%u implausible=%d", source,
+		    epoch_ms, uncertainty_ms, implausible);
+
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	bool had_valid = current.valid;
+	enum time_source held_source = current.source;
+	uint32_t held_uncertainty_ms = current_effective_uncertainty_locked();
+	bool meaningfully_tighter = (uint64_t)uncertainty_ms * 100 <=
+		(uint64_t)held_uncertainty_ms * (100 - TIME_CTX_RESYNC_IMPROVEMENT_PCT);
+	bool accept = !current.valid || implausible || meaningfully_tighter;
+
+	if (accept) {
+		current.valid = true;
+		current.source = source;
+		current.uncertainty_ms = uncertainty_ms;
+		current.synced_at_uptime_ms = k_uptime_get();
+	}
+
+	k_spin_unlock(&lock, key);
+
+	LOG_CTX_DBG("offer/decision",
+		    "had_valid=%d held_source=%d held_uncertainty_ms=%u meaningfully_tighter=%d "
+		    "accept=%d", had_valid, held_source, held_uncertainty_ms, meaningfully_tighter,
+		    accept);
+
+	if (accept) {
+		struct timespec tp = {
+			.tv_sec = (time_t)(epoch_ms / 1000),
+			.tv_nsec = (long)((epoch_ms % 1000) * 1000000),
+		};
+
+		sys_clock_settime(SYS_CLOCK_REALTIME, &tp);
+		LOG_INF("Accepted time offer: source=%d epoch_ms=%llu uncertainty_ms=%u", source,
+			epoch_ms, uncertainty_ms);
+	} else {
+		LOG_DBG("Rejected time offer: source=%d (not a meaningful improvement over "
+			"held source=%d)", source, held_source);
+	}
+
+	return accept;
+}
+
+uint32_t time_ctx_uncertainty_ms(void)
+{
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	uint32_t uncertainty_ms = current_effective_uncertainty_locked();
+
+	k_spin_unlock(&lock, key);
+
+	LOG_CTX_DBG("uncertainty", "uncertainty_ms=%u", uncertainty_ms);
+
+	return uncertainty_ms;
+}
+
+enum time_source time_ctx_source(void)
+{
+	k_spinlock_key_t key = k_spin_lock(&lock);
+
+	enum time_source source = current.source;
+
+	k_spin_unlock(&lock, key);
+
+	return source;
+}

+ 84 - 0
src/net/time_ctx.h

@@ -0,0 +1,84 @@
+/*
+ * The one place allowed to discipline this node's real wall clock
+ * (Zephyr's SYS_CLOCK_REALTIME, via sys_clock_settime()) and the one
+ * place that tracks how much to trust it. Every time source - NTP
+ * today (net_base.c), Beacon-derived coarse time today (beacon_rx.c),
+ * PTP and a local precision clock (GPS/WWVB/...) later - calls
+ * time_ctx_offer() with its own estimate and its own uncertainty bound,
+ * instead of touching the clock directly. time_ctx decides whether to
+ * accept each offer and, if so, steps the clock and remembers how
+ * uncertain that estimate was.
+ *
+ * Every source is treated uniformly - an estimate plus an uncertainty,
+ * not a source-priority table - so adding a new, better source later
+ * (PTP, a GPS/WWVB-driven local clock) is just a new caller, not a
+ * change to the decision rule. The rule itself:
+ *
+ *   Accept source's offer if EITHER:
+ *     - no estimate is currently held, or the currently held one has
+ *       decayed to where wall_clock_now_ms() would read before
+ *       BEACON_EPOCH_UNIX_MS (implausible - e.g. a node powering on
+ *       with no battery-backed clock), OR
+ *     - uncertainty_ms is strictly tighter than the currently held
+ *       estimate's *drift-grown* uncertainty (see TIME_CTX_DRIFT_PPM) -
+ *       i.e. a new estimate only ever replaces a better one, never a
+ *       worse one, regardless of source.
+ *
+ * This is the same shape NTP/PTP already use (stratum + dispersion,
+ * BMCA) in miniature - deliberately not reinvented from scratch, just
+ * sized for what this app actually has today.
+ */
+#ifndef TIME_CTX_H_
+#define TIME_CTX_H_
+
+#include <stdbool.h>
+#include <stdint.h>
+
+enum time_source {
+	TIME_SOURCE_NONE = 0,
+	TIME_SOURCE_BEACON,
+	TIME_SOURCE_NTP,
+	TIME_SOURCE_PTP,              /* not produced yet */
+	TIME_SOURCE_PRECISION_CLOCK,  /* GPS/WWVB/etc, not produced yet */
+};
+
+/* Zero-initializes the held estimate (no source, clock left wherever it
+ * powered on). Call once at boot, before any source can offer time.
+ */
+void time_ctx_init(void);
+
+/*
+ * Offers a new time estimate from source: epoch_ms is the proposed wall
+ * clock time (ms since the Unix epoch), uncertainty_ms is that source's
+ * own bound on how wrong epoch_ms could be *right now* (not grown for
+ * drift - time_ctx does that itself for whatever it ends up holding).
+ * Returns whether it was accepted; on accept, the real system clock is
+ * stepped via sys_clock_settime() and the estimate's quality is
+ * recorded for future time_ctx_uncertainty_ms() calls.
+ */
+bool time_ctx_offer(enum time_source source, uint64_t epoch_ms, uint32_t uncertainty_ms);
+
+/*
+ * Reads the real wall clock (ms since the Unix epoch) - same value
+ * net_base.c's old wall_clock_now_ms() returned, just relocated here.
+ * Meaningless (an uptime-ish value near zero) until some source's
+ * offer has been accepted - check time_ctx_source() != TIME_SOURCE_NONE
+ * first if that distinction matters to the caller.
+ */
+int64_t time_ctx_now_ms(void);
+
+/*
+ * Current uncertainty in time_ctx_now_ms(), drift-grown from whatever
+ * the last accepted offer's own uncertainty_ms was (see
+ * TIME_CTX_DRIFT_PPM) - a consumer's bound on how wrong "now" could be,
+ * not just whether a clock has ever been set. Returns UINT32_MAX if no
+ * offer has ever been accepted.
+ */
+uint32_t time_ctx_uncertainty_ms(void);
+
+/* Source of the currently held estimate, or TIME_SOURCE_NONE if no
+ * offer has ever been accepted.
+ */
+enum time_source time_ctx_source(void);
+
+#endif /* TIME_CTX_H_ */

+ 13 - 0
src/psram_bss.h

@@ -0,0 +1,13 @@
+/*
+ * Explicit per-symbol placement into PSRAM-backed BSS.
+ */
+#ifndef PSRAM_BSS_H_
+#define PSRAM_BSS_H_
+
+#ifdef CONFIG_ESP_SPIRAM
+#define PSRAM_BSS __attribute__((section(".ext_ram.bss")))
+#else
+#define PSRAM_BSS
+#endif
+
+#endif /* PSRAM_BSS_H_ */

+ 178 - 0
src/relay/cad_rx.c

@@ -0,0 +1,178 @@
+#include "relay/cad_rx.h"
+
+#include "frame/frame.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+#include "relay/relay_work.h"
+
+#include <zephyr/drivers/lora.h>
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/atomic.h>
+
+LOG_MODULE_REGISTER(cad_rx, LOG_LEVEL_CAD_RX);
+
+/*
+ * Bounds the capture window opened after a CAD hit: the longest this
+ * node's own radio config could take to receive a FRAME_MAX_LEN frame,
+ * plus margin for the CAD-to-RX arm gap and MCU/radio clock skew. A CAD
+ * hit that isn't followed by RX_DONE within this (noise, a non-LoRa
+ * signal CAD's detector mistook for a preamble, or a frame too long/
+ * wrong-config to decode) falls back to CAD rather than leaving the
+ * radio stuck listening.
+ */
+#define CAPTURE_WINDOW_MARGIN_MS 50
+
+enum cad_rx_op {
+	CAD_RX_OP_IDLE,
+	CAD_RX_OP_CAD,
+	CAD_RX_OP_CAPTURE,
+};
+
+static const struct device *lora_dev;
+static lora_recv_cb frame_cb;
+static void *frame_cb_user_data;
+static uint32_t capture_window_ms;
+
+static atomic_t paused;
+static atomic_t current_op;
+
+static struct k_work_delayable capture_timeout_work;
+
+static void on_cad_done(const struct device *dev, bool activity_detected, void *user_data);
+
+static void arm_cad(void)
+{
+	atomic_set(&current_op, CAD_RX_OP_CAD);
+
+	int ret = lora_cad_async(lora_dev, on_cad_done, NULL);
+
+	if (ret < 0) {
+		LOG_ERR("lora_cad_async failed: %d - CAD-RX stalled until cad_rx_resume()", ret);
+		atomic_set(&current_op, CAD_RX_OP_IDLE);
+		return;
+	}
+
+	/* cad_rx_pause() may have run on another context between the call
+	 * above committing and this check - best-effort unwind, same
+	 * tolerance tx_queue's own pause/resume already relies on (see
+	 * cad_rx.h).
+	 */
+	if (atomic_get(&paused)) {
+		lora_cad_async(lora_dev, NULL, NULL);
+		atomic_set(&current_op, CAD_RX_OP_IDLE);
+	}
+}
+
+static void capture_timeout_handler(struct k_work *work)
+{
+	ARG_UNUSED(work);
+
+	if (!atomic_cas(&current_op, CAD_RX_OP_CAPTURE, CAD_RX_OP_IDLE)) {
+		/* on_capture_rx() already claimed this capture window - a
+		 * real frame won the race against this timeout.
+		 */
+		return;
+	}
+
+	LOG_CTX_DBG("cad_rx", "capture window expired, no RX_DONE - back to CAD");
+	lora_recv_async(lora_dev, NULL, NULL);
+
+	if (!atomic_get(&paused)) {
+		arm_cad();
+	}
+}
+
+static void on_capture_rx(const struct device *dev, uint8_t *data, uint16_t size, int16_t rssi,
+			   int8_t snr, void *user_data)
+{
+	ARG_UNUSED(user_data);
+
+	if (atomic_cas(&current_op, CAD_RX_OP_CAPTURE, CAD_RX_OP_IDLE)) {
+		k_work_cancel_delayable(&capture_timeout_work);
+		/* Stop continuous RX after this one frame instead of
+		 * letting the driver auto-restart it (see lora_recv_async()'s
+		 * "can be done within the callback handler") - CAD-RX wants
+		 * one captured frame per CAD hit, then back to polling.
+		 */
+		lora_recv_async(dev, NULL, NULL);
+	}
+
+	frame_cb(dev, data, size, rssi, snr, frame_cb_user_data);
+
+	if (!atomic_get(&paused)) {
+		arm_cad();
+	}
+}
+
+static void on_cad_done(const struct device *dev, bool activity_detected, void *user_data)
+{
+	ARG_UNUSED(user_data);
+
+	if (atomic_get(&paused)) {
+		atomic_set(&current_op, CAD_RX_OP_IDLE);
+		return;
+	}
+
+	if (!activity_detected) {
+		arm_cad();
+		return;
+	}
+
+	LOG_CTX_DBG("cad_rx", "activity detected, opening %u ms capture window",
+		    capture_window_ms);
+
+	atomic_set(&current_op, CAD_RX_OP_CAPTURE);
+
+	int ret = lora_recv_async(dev, on_capture_rx, NULL);
+
+	if (ret < 0) {
+		LOG_ERR("lora_recv_async (capture) failed: %d", ret);
+		atomic_set(&current_op, CAD_RX_OP_IDLE);
+		if (!atomic_get(&paused)) {
+			arm_cad();
+		}
+		return;
+	}
+
+	if (atomic_get(&paused)) {
+		lora_recv_async(dev, NULL, NULL);
+		atomic_set(&current_op, CAD_RX_OP_IDLE);
+		return;
+	}
+
+	k_work_schedule_for_queue(&relay_work_q, &capture_timeout_work, K_MSEC(capture_window_ms));
+}
+
+void cad_rx_start(const struct device *dev, lora_recv_cb cb, void *user_data)
+{
+	lora_dev = dev;
+	frame_cb = cb;
+	frame_cb_user_data = user_data;
+	capture_window_ms = lora_airtime(dev, FRAME_MAX_LEN) + CAPTURE_WINDOW_MARGIN_MS;
+
+	LOG_INF("CAD-RX starting: capture_window_ms=%u", capture_window_ms);
+
+	k_work_init_delayable(&capture_timeout_work, capture_timeout_handler);
+	atomic_set(&paused, 0);
+	arm_cad();
+}
+
+void cad_rx_pause(void)
+{
+	atomic_set(&paused, 1);
+	k_work_cancel_delayable(&capture_timeout_work);
+	/* Unconditional cancel of both - whichever one isn't actually
+	 * armed is a harmless no-op in the driver (see sx126x_lora_cad_async()/
+	 * sx126x_lora_recv_async()'s cb == NULL paths).
+	 */
+	lora_cad_async(lora_dev, NULL, NULL);
+	lora_recv_async(lora_dev, NULL, NULL);
+	atomic_set(&current_op, CAD_RX_OP_IDLE);
+}
+
+void cad_rx_resume(void)
+{
+	atomic_set(&paused, 0);
+	arm_cad();
+}

+ 43 - 0
src/relay/cad_rx.h

@@ -0,0 +1,43 @@
+/*
+ * CAD-triggered RX: replaces continuous RX with a repeating "CAD, then
+ * capture only if something's there" cycle. Bench-only experiment for
+ * now (see RX_MODE_CAD_TRIGGER in main.c) - continuous RX stays the
+ * shipped default until this has been field-tested; the point of this
+ * module is to let the two modes be swapped with no other code change,
+ * so tx_queue (the only other thing that touches RX arm/disarm) talks to
+ * whichever mode is active only through cad_rx_pause()/cad_rx_resume()
+ * or the continuous-RX equivalents in main.c, never to lora_recv_async()
+ * directly.
+ *
+ * Same single-callpoint reasoning as tx_queue.h: one module owns the
+ * CAD/RX state machine for this mode, so nothing else has to reason
+ * about whether the radio is mid-CAD or mid-capture.
+ */
+#ifndef CAD_RX_H_
+#define CAD_RX_H_
+
+#include <zephyr/device.h>
+#include <zephyr/drivers/lora.h>
+
+/*
+ * Starts the CAD-poll/capture cycle. cb/user_data are delivered exactly
+ * like lora_recv_async()'s - called with a decoded frame whenever a CAD
+ * hit turns into a real RX_DONE within the capture window. Must be
+ * called once at boot, after lora_config().
+ */
+void cad_rx_start(const struct device *dev, lora_recv_cb cb, void *user_data);
+
+/*
+ * Cancels whatever's in flight (a pending CAD or an open capture window)
+ * and leaves the radio idle. Safe to call from any context other than
+ * the CAD/RX callback itself; best-effort against the callback racing
+ * in from the driver's IRQ workqueue at the same moment (same level of
+ * race tolerance tx_queue's existing pause/resume of continuous RX
+ * already relies on - see tx_queue.c's process_item()).
+ */
+void cad_rx_pause(void);
+
+/* Resumes CAD polling after cad_rx_pause(). */
+void cad_rx_resume(void);
+
+#endif /* CAD_RX_H_ */

+ 290 - 0
src/relay/cbf.c

@@ -0,0 +1,290 @@
+#include "relay/cbf.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+#include "relay/relay_work.h"
+#include "relay/topology.h"
+#include "relay/tx_queue.h"
+
+#include <string.h>
+
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/random/random.h>
+
+LOG_MODULE_REGISTER(cbf, LOG_LEVEL_CBF);
+
+/* Open item per the design doc: real values come from measured RX->TX
+ * turnaround + preamble-detect latency at the chosen SF/BW. Placeholders
+ * for this bring-up.
+ */
+#define T_STEP_MS     200
+#define JITTER_MAX_MS 50
+
+#define SEEN_SET_SIZE 8
+#define SEEN_TTL_MS   60000
+
+struct seen_entry {
+	uint8_t src;
+	uint16_t seq;
+	int64_t at_ms;
+	bool valid;
+};
+
+static void on_forward_timer(struct k_work *work);
+
+static uint8_t self_id;
+
+static struct seen_entry seen[SEEN_SET_SIZE];
+
+static struct {
+	struct k_work_delayable work;
+	uint8_t buf[FRAME_MAX_LEN];
+	size_t len;
+	uint8_t src;
+	uint16_t seq;
+	bool pending;
+} fwd;
+
+void cbf_init(uint8_t id)
+{
+	self_id = id;
+	k_work_init_delayable(&fwd.work, on_forward_timer);
+}
+
+static bool seen_check_and_add(uint8_t src, uint16_t seq)
+{
+	int64_t now = k_uptime_get();
+
+	for (int i = 0; i < SEEN_SET_SIZE; i++) {
+		if (seen[i].valid && (now - seen[i].at_ms) > SEEN_TTL_MS) {
+			seen[i].valid = false;
+		}
+		if (seen[i].valid && seen[i].src == src && seen[i].seq == seq) {
+			LOG_CTX_DBG("seen_check", "src=%u seq=%u duplicate=1 slot=%d", src, seq, i);
+			return true;
+		}
+	}
+
+	/* Not found: add it, evicting the oldest slot if full. */
+	int oldest = 0;
+
+	for (int i = 0; i < SEEN_SET_SIZE; i++) {
+		if (!seen[i].valid) {
+			oldest = i;
+			break;
+		}
+		if (seen[i].at_ms < seen[oldest].at_ms) {
+			oldest = i;
+		}
+	}
+
+	seen[oldest] = (struct seen_entry){
+		.src = src, .seq = seq, .at_ms = now, .valid = true,
+	};
+	LOG_CTX_DBG("seen_check", "src=%u seq=%u duplicate=0 slot=%d", src, seq, oldest);
+	return false;
+}
+
+static bool beyond_transmitter(const struct frame_hdr *hdr)
+{
+	uint8_t my_ord = topology_ordinal(self_id);
+	uint8_t tx_ord = topology_ordinal(hdr->tx);
+
+	if (hdr->dir == FRAME_DIR_DOWNSTREAM) {
+		return my_ord > tx_ord;
+	}
+	return my_ord < tx_ord;
+}
+
+/* Mirror of beyond_transmitter: true when hdr->tx made more progress
+ * toward DST than we have, i.e. it's grounds to stand down our own
+ * pending forward. A relay heard from level with or behind us (the
+ * "wrong direction") doesn't mean DST actually got it, so it's tracked
+ * for dedup/ack bookkeeping only - it must not disarm us.
+ */
+static bool relayed_closer_to_dst(const struct frame_hdr *hdr)
+{
+	uint8_t my_ord = topology_ordinal(self_id);
+	uint8_t tx_ord = topology_ordinal(hdr->tx);
+
+	if (hdr->dir == FRAME_DIR_DOWNSTREAM) {
+		return tx_ord > my_ord;
+	}
+	return tx_ord < my_ord;
+}
+
+static uint8_t progress(const struct frame_hdr *hdr)
+{
+	uint8_t my_ord = topology_ordinal(self_id);
+	uint8_t tx_ord = topology_ordinal(hdr->tx);
+
+	return (hdr->dir == FRAME_DIR_DOWNSTREAM) ? (my_ord - tx_ord) : (tx_ord - my_ord);
+}
+
+static bool on_path_to_dst(const struct frame_hdr *hdr)
+{
+	if (hdr->dst == FRAME_DST_ALL) {
+		return true;
+	}
+	/* Single line, no branching yet: everyone is on the path. */
+	return true;
+}
+
+static bool node_exists_beyond_me_toward(const struct frame_hdr *hdr)
+{
+	uint8_t my_ord = topology_ordinal(self_id);
+
+	if (hdr->dst != FRAME_DST_ALL) {
+		uint8_t dst_ord = topology_ordinal(hdr->dst);
+
+		return (hdr->dir == FRAME_DIR_DOWNSTREAM) ? (my_ord < dst_ord)
+							   : (my_ord > dst_ord);
+	}
+	/* Flood: stop at the known edge of the (hardcoded) graph, in
+	 * whichever direction this is moving.
+	 */
+	return (hdr->dir == FRAME_DIR_DOWNSTREAM) ? (my_ord < TOPOLOGY_MAX_ORDINAL) : (my_ord > 0);
+}
+
+/* Restamp TX<-self_id and transmit raw/raw_len. Shared by the normal,
+ * delayed/jittered forward (on_forward_timer) and the terminal-node
+ * immediate forward (cbf_on_rx) - same restamp+send+resume-RX sequence
+ * either way, just with or without a wait beforehand.
+ */
+static void relay_transmit(const uint8_t *raw, size_t raw_len)
+{
+	uint8_t buf[FRAME_MAX_LEN];
+
+	if (raw_len > sizeof(buf)) {
+		LOG_ERR("CBF relay: frame too large to transmit (%zu > %d)", raw_len,
+			FRAME_MAX_LEN);
+		return;
+	}
+	memcpy(buf, raw, raw_len);
+
+	int ret = frame_restamp_tx(buf, raw_len, self_id);
+
+	if (ret < 0) {
+		LOG_ERR("CBF relay restamp failed: %d", ret);
+		return;
+	}
+
+	/* tx_queue itself logs "a frame went out" generically (ver/dir/
+	 * relay/ptype/TX/SRC/DST/SEQ/payload_len) for every submission, so
+	 * this doesn't re-decode/re-log the restamped frame - see
+	 * tx_queue.c.
+	 */
+	int submit_ret = tx_queue_submit(buf, raw_len, NULL, NULL);
+
+	if (submit_ret < 0) {
+		LOG_ERR("CBF relay tx_queue_submit failed: %d", submit_ret);
+	}
+}
+
+static void on_forward_timer(struct k_work *work)
+{
+	ARG_UNUSED(work);
+
+	LOG_CTX_DBG("forward_timer", "pending=%d src=%u seq=%u", fwd.pending, fwd.src, fwd.seq);
+
+	if (!fwd.pending) {
+		return;
+	}
+	fwd.pending = false;
+
+	relay_transmit(fwd.buf, fwd.len);
+}
+
+void cbf_on_rx(const struct frame_hdr *hdr, const uint8_t *raw, size_t raw_len)
+{
+	LOG_CTX_DBG("rx", "src=%u seq=%u tx=%u dst=%u dir=%d relay=%d", hdr->src, hdr->seq, hdr->tx,
+		    hdr->dst, hdr->dir, hdr->relay);
+
+	if (hdr->relay == FRAME_RELAY_DIRECT_ONLY) {
+		LOG_CTX_DBG("rx/gate", "src=%u seq=%u direct_only=1 - never chains through CBF",
+			    hdr->src, hdr->seq);
+		return; /* never chains through CBF */
+	}
+	if (!on_path_to_dst(hdr)) {
+		LOG_CTX_DBG("rx/gate", "src=%u seq=%u on_path_to_dst=0", hdr->src, hdr->seq);
+		return;
+	}
+
+	/* Dedup tracking runs before the direction gate below, regardless of
+	 * where this copy came from - we want every heard relay recorded
+	 * for ack/dedup bookkeeping. But only disarm our own pending forward
+	 * on a relay that actually made more progress toward DST
+	 * (relayed_closer_to_dst): a wrong-direction relay is tracked, not
+	 * acted on - we stay armed and still fire if nothing closer to DST
+	 * beats us to it.
+	 */
+	bool already_seen = seen_check_and_add(hdr->src, hdr->seq);
+
+	LOG_CTX_DBG("rx/dedup", "src=%u seq=%u already_seen=%d", hdr->src, hdr->seq, already_seen);
+
+	if (already_seen) {
+		bool relayed_closer = relayed_closer_to_dst(hdr);
+		bool disarm = fwd.pending && fwd.src == hdr->src && fwd.seq == hdr->seq &&
+			      relayed_closer;
+
+		LOG_CTX_DBG("rx/disarm_check", "src=%u seq=%u heard_tx=%u fwd_pending=%d "
+			    "relayed_closer=%d disarm=%d", hdr->src, hdr->seq, hdr->tx,
+			    fwd.pending, relayed_closer, disarm);
+
+		if (disarm) {
+			fwd.pending = false;
+			k_work_cancel_delayable(&fwd.work);
+		}
+		return;
+	}
+
+	bool beyond_tx = beyond_transmitter(hdr);
+
+	LOG_CTX_DBG("rx/candidate", "src=%u seq=%u beyond_transmitter=%d", hdr->src, hdr->seq,
+		    beyond_tx);
+
+	if (!beyond_tx) {
+		return; /* not a candidate to forward this myself */
+	}
+
+	bool node_beyond = node_exists_beyond_me_toward(hdr);
+
+	LOG_CTX_DBG("rx/edge_check", "src=%u seq=%u node_exists_beyond_me=%d", hdr->src, hdr->seq,
+		    node_beyond);
+
+	if (!node_beyond) {
+		/* At DST itself, or at the edge of the known graph: nobody to
+		 * hand this to further. Still forward once, immediately (no
+		 * CBF delay/jitter - nothing beyond me to contend with), so
+		 * the previous hop gets an explicit ack via the same
+		 * overhear-the-relay mechanism as every other hop. Without
+		 * this, the one node with no one further downstream is also
+		 * the one node whose receipt nobody ever confirms.
+		 */
+		LOG_CTX_DBG("rx/edge_forward", "src=%u seq=%u immediate relay (no one beyond me)",
+			    hdr->src, hdr->seq);
+		relay_transmit(raw, raw_len);
+		return;
+	}
+
+	if (raw_len > FRAME_MAX_LEN) {
+		LOG_ERR("CBF: frame too large to buffer for relay (%zu > %d)",
+			raw_len, FRAME_MAX_LEN);
+		return;
+	}
+
+	uint8_t prog = progress(hdr);
+	uint32_t delay_ms = T_STEP_MS * (TOPOLOGY_MAX_ORDINAL - prog) +
+			     (sys_rand32_get() % JITTER_MAX_MS);
+
+	LOG_CTX_DBG("rx/schedule", "src=%u seq=%u progress=%u delay_ms=%u", hdr->src, hdr->seq,
+		    prog, delay_ms);
+
+	memcpy(fwd.buf, raw, raw_len);
+	fwd.len = raw_len;
+	fwd.src = hdr->src;
+	fwd.seq = hdr->seq;
+	fwd.pending = true;
+
+	k_work_schedule_for_queue(&relay_work_q, &fwd.work, K_MSEC(delay_ms));
+}

+ 27 - 0
src/relay/cbf.h

@@ -0,0 +1,27 @@
+/*
+ * Directional contention forwarding, per "Relay Transport and Directional
+ * Forwarding" / *Directional contention forwarding*. This milestone only
+ * exercises the downstream, DST=0xFF flood case (Beacon traffic), on a
+ * single line with one phantom leaf - see topology.h.
+ */
+#ifndef CBF_H_
+#define CBF_H_
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "frame/frame.h"
+
+/* Must be called once at boot. CBF no longer touches the radio directly
+ * (see tx_queue.h) - it only restamps TX and submits to the TX queue.
+ */
+void cbf_init(uint8_t self_id);
+
+/* Feed every received, MAC-verified frame to the CBF engine. hdr is the
+ * already-decoded header; raw/raw_len are the full on-air bytes
+ * (header+payload+MAC) as received, needed so a scheduled forward can
+ * restamp TX and retransmit the same bytes.
+ */
+void cbf_on_rx(const struct frame_hdr *hdr, const uint8_t *raw, size_t raw_len);
+
+#endif /* CBF_H_ */

+ 27 - 0
src/relay/relay_work.c

@@ -0,0 +1,27 @@
+#include "relay/relay_work.h"
+
+/*
+ * Sized for the deepest call chain scheduled here: frame encode/decode
+ * through the group-MAC HMAC-SHA256 (PSA crypto) plus logging - the
+ * same depth CONFIG_MAIN_STACK_SIZE is set for. 1024 (the system
+ * workqueue's own default) was tried and silently overran it before
+ * CONFIG_STACK_SENTINEL caught a near-identical overflow elsewhere.
+ */
+#define RELAY_WORK_STACK_SIZE 4096
+
+/* Same priority the system workqueue defaults to (CONFIG_SYSTEM_
+ * WORKQUEUE_PRIORITY) - no reason for this app's own work to run at a
+ * different priority than Zephyr's own default choice would.
+ */
+#define RELAY_WORK_PRIORITY -1
+
+struct k_work_q relay_work_q;
+static K_THREAD_STACK_DEFINE(relay_work_stack, RELAY_WORK_STACK_SIZE);
+
+void relay_work_q_start(void)
+{
+	k_work_queue_start(&relay_work_q, relay_work_stack,
+			   K_THREAD_STACK_SIZEOF(relay_work_stack),
+			   RELAY_WORK_PRIORITY, NULL);
+	k_thread_name_set(k_work_queue_thread_get(&relay_work_q), "relay_work");
+}

+ 22 - 0
src/relay/relay_work.h

@@ -0,0 +1,22 @@
+/*
+ * A workqueue dedicated to this app's own scheduled work (beacon TX,
+ * CBF relay forwards) - kept off the system workqueue because both
+ * call the blocking lora_send()/lora_recv_async(), and the sx126x
+ * driver's own IRQ-completion handling also runs there by default. A
+ * handler that blocks waiting for that completion, on the same queue
+ * that's supposed to deliver it, deadlocks: the queue can't dequeue
+ * the next item (the IRQ work) until the current one returns, and the
+ * current one won't return until the IRQ work runs. See "sx126x
+ * Zephyr TX deadlock fix" in project notes.
+ */
+#ifndef RELAY_WORK_H_
+#define RELAY_WORK_H_
+
+#include <zephyr/kernel.h>
+
+/* Must be called once at boot, before scheduling anything onto it. */
+void relay_work_q_start(void);
+
+extern struct k_work_q relay_work_q;
+
+#endif /* RELAY_WORK_H_ */

+ 36 - 0
src/relay/topology.h

@@ -0,0 +1,36 @@
+/*
+ * Hardcoded provisioning data for the 3-real-node bring-up: central (0)
+ * and two relays (1, 2), with node 2 as the real edge of the line - no
+ * phantom leaf. node_exists_beyond_me_toward() is false for node 2, so it
+ * takes the terminal-ack path in cbf.c instead of a normal scheduled
+ * forward. Single line, so ordinal == node id; no branching yet.
+ */
+#ifndef TOPOLOGY_H_
+#define TOPOLOGY_H_
+
+#include <stdint.h>
+
+#include "frame/frame.h"
+
+#define TOPOLOGY_MAX_ORDINAL 2 /* node 2 is the real edge, no phantom leaf */
+
+static inline uint8_t topology_ordinal(uint8_t node_id)
+{
+	return node_id;
+}
+
+/*
+ * Which way a frame from src to dst moves on the tree path through
+ * central: downstream (away from central) if dst sits farther out than
+ * src, upstream otherwise. Any frame_hdr originator not itself central
+ * (e.g. a Msg reply between two non-central peers) still fits this -
+ * "upstream"/"downstream" is about direction along the path, not about
+ * central being one of the two endpoints.
+ */
+static inline enum frame_dir topology_dir(uint8_t src, uint8_t dst)
+{
+	return (topology_ordinal(dst) > topology_ordinal(src)) ? FRAME_DIR_DOWNSTREAM
+								: FRAME_DIR_UPSTREAM;
+}
+
+#endif /* TOPOLOGY_H_ */

+ 356 - 0
src/relay/tx_queue.c

@@ -0,0 +1,356 @@
+#include "relay/tx_queue.h"
+
+#include "beacon/beacon_ctx.h"
+#include "frame/frame.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+#include "net/peer_stats.h"
+#include "frame/pkt_log.h"
+
+#include <errno.h>
+#include <string.h>
+
+#include <zephyr/drivers/lora.h>
+#include <zephyr/drivers/lora/sx126x.h>
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/random/random.h>
+
+LOG_MODULE_REGISTER(tx_queue, LOG_LEVEL_TX_QUEUE);
+
+/* Sized the same as relay_work_q's own stack (see relay_work.c) - every
+ * TX call site this replaced was already running lora_send()/
+ * lora_recv_async() at that depth without a CONFIG_STACK_SENTINEL trip,
+ * so match it rather than guess a smaller number for a path this module
+ * didn't change (the SPI-adjacent driver call depth, not this file's own
+ * logic, is what sets the floor here).
+ */
+#define TX_QUEUE_STACK_SIZE 4096
+
+/* Same default the system workqueue (and relay_work_q) run at - no
+ * reason for this app's own TX draining to run at a different priority.
+ */
+#define TX_QUEUE_PRIORITY -1
+
+/*
+ * Minimum spacing enforced between the end of our own last TX and the
+ * start of the next one - a receiver needs time to finish settling/
+ * reading out one frame (RX_DONE IRQ, SPI buffer readout, AGC/sync
+ * recovery) before the next one's preamble arrives, or demod of the
+ * second frame corrupts.
+ *
+ * 20ms is the floor measured on the 3-node bench (SF7/BW500, -9 dBm
+ * bench power - see LORA_BENCH_TEST in main.c) - not rigorously
+ * characterized (one bench setup, one SF/BW/power config) and not a
+ * replacement for real duty-cycle gating (still open, see tx_queue.h).
+ * Revisit if the radio config changes.
+ *
+ * TODO once cad_rx.c's CAD-triggered RX (RX_MODE_CAD_TRIGGER, main.c) is
+ * bench power-tested: re-measure this floor under that mode. CAD-RX's
+ * own capture-window/re-arm cycle already imposes some minimum quiet
+ * period after the sender's own LBT CAD, so this guard may end up able
+ * to shrink (or go away) once that path is exercised - don't assume
+ * 20ms still applies unchanged.
+ */
+#define TX_MIN_INTERVAL_MS 20
+
+/*
+ * Listen-before-talk: a bounded number of CAD attempts before each TX,
+ * backed off by a random jitter when the channel's busy, to cut down
+ * collisions with other transmitters sharing the channel. This is
+ * best-effort collision avoidance, not compliance LBT - after
+ * LBT_MAX_ATTEMPTS the frame goes out regardless, since this is the
+ * node's one TX callsite and nothing else drains tx_msgq while this
+ * frame sits here.
+ *
+ * LBT_CAD_TIMEOUT_MS covers the slowest CAD the driver's own SF/BW
+ * defaults can pick (SF12, up to 8 symbols - see
+ * sx126x_cad_lookup_defaults() in the native driver's CAD patch) with
+ * margin; any faster config's CAD finishes well under this via the
+ * CadDone IRQ; this is only a ceiling for a stuck case.
+ */
+#define LBT_CAD_TIMEOUT_MS 300
+#define LBT_MAX_ATTEMPTS   3
+#define LBT_BACKOFF_MAX_MS 50
+
+struct tx_item {
+	uint8_t buf[FRAME_MAX_LEN];
+	size_t len;
+	tx_queue_done_cb cb;
+	void *user_data;
+	struct k_sem *done_sem;
+	struct tx_queue_done_info *done_info;
+
+	/* Acquired in enqueue(), released at the end of process_item() -
+	 * not exposed to any tx_queue caller today (no TX-side consumer
+	 * needs it yet, same as cb/done_info being optional), but every
+	 * queued item carries one so a future worker added inside
+	 * process_item() (e.g. signing against the current pubkey table)
+	 * sees a consistent snapshot for this item's whole queued
+	 * lifetime, symmetric with the RX dispatch path (main.c) attaching
+	 * one to each rx_work packet. See beacon_ctx.h.
+	 */
+	struct beacon_ctx_ref bctx;
+};
+
+K_MSGQ_DEFINE(tx_msgq, sizeof(struct tx_item), TX_QUEUE_DEPTH, 4);
+
+static const struct device *lora_dev;
+static tx_queue_rx_pause_fn rx_pause;
+static tx_queue_rx_resume_fn rx_resume;
+
+/* TX_MIN_INTERVAL_MS's floor - k_uptime_get()'s timebase, same one the
+ * TX_DONE IRQ timestamp peek converts to (see process_item()). No
+ * previous TX yet until have_last_tx_done is set, so the first-ever
+ * send is never held up waiting on it.
+ */
+static int64_t last_tx_done_uptime_ms;
+static bool have_last_tx_done;
+
+static K_THREAD_STACK_DEFINE(tx_queue_stack, TX_QUEUE_STACK_SIZE);
+static struct k_thread tx_queue_thread;
+
+static void process_item(struct tx_item *item)
+{
+	pkt_log_raw("TX raw", item->buf, item->len);
+
+	/* The one place that logs "a frame went out" - every originator
+	 * (Beacon, test MSG) and CBF relay forward funnels through here,
+	 * so none of them log this themselves anymore (see main.c/cbf.c).
+	 * Mirrors main.c's rx_work_handler decoding+logging "RX frame: ..."
+	 * once at its own single RX callpoint.
+	 */
+	struct frame_hdr hdr;
+	const uint8_t *payload;
+	size_t payload_len;
+	bool decoded = frame_decode_hdr(item->buf, item->len, &hdr, &payload, &payload_len) == 0;
+
+	if (decoded) {
+		LOG_INF("TX frame: ver=%u dir=%u relay=%u ptype=%u TX=%u SRC=%u DST=%u SEQ=%u "
+			"payload_len=%zu", hdr.ver, hdr.dir, hdr.relay, hdr.ptype, hdr.tx, hdr.src,
+			hdr.dst, hdr.seq, payload_len);
+	} else {
+		LOG_WRN("TX frame: header decode failed (len=%zu) - sending raw bytes anyway",
+			item->len);
+	}
+
+	/* Minimum spacing since our own last completed TX - see
+	 * TX_MIN_INTERVAL_MS.
+	 */
+	if (have_last_tx_done) {
+		int64_t elapsed_ms = k_uptime_get() - last_tx_done_uptime_ms;
+
+		if (elapsed_ms < TX_MIN_INTERVAL_MS) {
+			LOG_CTX_DBG("min_interval", "elapsed_ms=%lld sleeping_ms=%lld", elapsed_ms,
+				    TX_MIN_INTERVAL_MS - elapsed_ms);
+			k_sleep(K_MSEC(TX_MIN_INTERVAL_MS - elapsed_ms));
+		} else {
+			LOG_CTX_DBG("min_interval", "elapsed_ms=%lld no_wait", elapsed_ms);
+		}
+	}
+
+	/* Pause RX before TX, same as every call site used to do inline -
+	 * the sx126x can't RX and TX at once, and leaving async RX armed
+	 * during lora_send() raced the driver's own state. This also
+	 * settles the driver's software state machine back to REST/IDLE
+	 * synchronously (the HAL's SPI writes block on the chip's BUSY
+	 * line internally) before lora_send() below ever runs, which is
+	 * what gives us "radio idle/ready before the next TX" for free -
+	 * lora_send()'s own atomic_cas(REST_STATE, ...) would otherwise
+	 * fail with -EBUSY instead of silently racing the chip.
+	 */
+	rx_pause();
+
+	/* Listen-before-talk - see LBT_* above. sx126x_lora_send_lbt() does
+	 * the CAD and, if clear, the send as one driver call with no sleep/
+	 * wake round-trip between the two, unlike a separate
+	 * lora_cad()+lora_send() (the app-layer form this replaced - 3-node
+	 * bench A/B on 2026-11-07, SF7/BW500, measured this ~1.6-1.7ms
+	 * faster per clear-channel send, plus one continuous radio-lock for
+	 * the whole CAD+TX op instead of two separate claim/release cycles).
+	 * A negative return means LBT itself isn't available for the
+	 * current config (e.g. -ENOTSUP for a bandwidth AN1200.48 doesn't
+	 * cover); fall through to a plain lora_send(), same as before CAD
+	 * existed.
+	 */
+	int ret = -ENOTSUP;
+
+	for (int attempt = 0; attempt < LBT_MAX_ATTEMPTS; attempt++) {
+		int lbt_ret = sx126x_lora_send_lbt(lora_dev, item->buf, item->len,
+						    K_MSEC(LBT_CAD_TIMEOUT_MS));
+
+		if (lbt_ret != 1) {
+			/* Sent (0), or LBT unavailable/errored (<0) - either
+			 * way, nothing left for this loop to retry.
+			 */
+			ret = lbt_ret;
+			break;
+		}
+
+		if (attempt == LBT_MAX_ATTEMPTS - 1) {
+			LOG_WRN("LBT: channel still busy after %d attempts, sending anyway",
+				LBT_MAX_ATTEMPTS);
+			ret = lbt_ret;
+			break;
+		}
+
+		uint32_t backoff_ms = sys_rand32_get() % LBT_BACKOFF_MAX_MS;
+
+		LOG_CTX_DBG("lbt", "channel busy (attempt %d/%d), backoff_ms=%u", attempt + 1,
+			    LBT_MAX_ATTEMPTS, backoff_ms);
+		k_sleep(K_MSEC(backoff_ms));
+	}
+
+	if (ret == 1 || ret == -ENOTSUP) {
+		if (ret == -ENOTSUP) {
+			LOG_CTX_DBG("lbt", "send_lbt unavailable (err=%d), sending without LBT",
+				    ret);
+		}
+		ret = lora_send(lora_dev, item->buf, item->len);
+	}
+
+	if (ret < 0) {
+		LOG_ERR("lora_send failed: %d", ret);
+	}
+
+	/* Peek the TX_DONE IRQ timestamp now, right after lora_send()
+	 * returns - same one-shot-slot reasoning as main.c's on_rx() peek
+	 * for RX_DONE: the resume_rx_cb below re-arms RX, and a frame
+	 * heard immediately after would overwrite this slot with its own
+	 * RX_DONE event first. Raw ns, kept local - used immediately below
+	 * for peer_stats (same single-call pattern as main.c's own direct
+	 * peer_stats calls off rx_work.rx_done_ns), never held past this
+	 * function the way the struct below is.
+	 */
+	uint64_t tx_done_ns;
+	bool tx_done_ns_valid =
+		sx126x_irq_timestamp_get(lora_dev, SX126X_IRQ_TS_TX_DONE, &tx_done_ns);
+
+	/*
+	 * sx126x_irq_timestamp_get()'s ns is already an absolute,
+	 * k_uptime_get()-comparable value (resolved in the driver itself -
+	 * see zephyr-patches/0001's commit message for why that has to
+	 * happen there, not here), so this is a plain unit conversion.
+	 * tx_queue_done_info is this module's one context a caller can
+	 * hold onto past the moment of the peek - this function's own
+	 * TX_MIN_INTERVAL_MS gate below is exactly that kind of caller,
+	 * holding last_tx_done_uptime_ms across tens of seconds between
+	 * sends (this node's own 30s Ping cadence).
+	 */
+	struct tx_queue_done_info info = {
+		.status = ret,
+		.tx_done_valid = tx_done_ns_valid,
+		.tx_done_uptime_ms =
+			tx_done_ns_valid ? (int64_t)(tx_done_ns / 1000000ULL) : k_uptime_get(),
+	};
+
+	last_tx_done_uptime_ms = info.tx_done_uptime_ms;
+	have_last_tx_done = true;
+
+	/* Keyed by hdr.dst - self-originated sends only (hdr.src == NODE_ID),
+	 * not CBF relay forwards of someone else's traffic: the whole point
+	 * of this counter is to compare 1:1 against the destination peer's
+	 * own rx_unicast_count for us (see peer_stats.h), which a relay
+	 * forward - still carrying the original src, not us - would never
+	 * match against. NODE_ID comes from the same build-time -DNODE_ID
+	 * flag net_base.c already relies on without its own fallback.
+	 */
+	if (decoded && hdr.src == NODE_ID) {
+		peer_stats_record_tx_to(hdr.dst, tx_done_ns, tx_done_ns_valid);
+	}
+
+	rx_resume();
+
+	LOG_CTX_DBG("done", "status=%d tx_done_valid=%d tx_done_uptime_ms=%lld", info.status,
+		    info.tx_done_valid, info.tx_done_uptime_ms);
+
+	if (item->done_info) {
+		*item->done_info = info;
+	}
+	if (item->cb) {
+		item->cb(item->user_data, &info);
+	}
+	if (item->done_sem) {
+		k_sem_give(item->done_sem);
+	}
+
+	beacon_ctx_release(item->bctx);
+}
+
+static void tx_queue_thread_fn(void *p1, void *p2, void *p3)
+{
+	ARG_UNUSED(p1);
+	ARG_UNUSED(p2);
+	ARG_UNUSED(p3);
+
+	struct tx_item item;
+
+	while (1) {
+		k_msgq_get(&tx_msgq, &item, K_FOREVER);
+		process_item(&item);
+	}
+}
+
+void tx_queue_start(const struct device *dev, tx_queue_rx_pause_fn pause_fn,
+		     tx_queue_rx_resume_fn resume_fn)
+{
+	lora_dev = dev;
+	rx_pause = pause_fn;
+	rx_resume = resume_fn;
+
+	k_thread_create(&tx_queue_thread, tx_queue_stack, K_THREAD_STACK_SIZEOF(tx_queue_stack),
+			 tx_queue_thread_fn, NULL, NULL, NULL, TX_QUEUE_PRIORITY, 0, K_NO_WAIT);
+	k_thread_name_set(&tx_queue_thread, "tx_queue");
+}
+
+static int enqueue(const uint8_t *buf, size_t len, tx_queue_done_cb cb, void *user_data,
+		    struct k_sem *done_sem, struct tx_queue_done_info *done_info)
+{
+	if (len > FRAME_MAX_LEN) {
+		LOG_ERR("tx_queue_submit: frame too large (%zu > %d)", len, FRAME_MAX_LEN);
+		return -EINVAL;
+	}
+
+	struct tx_item item = {
+		.len = len,
+		.cb = cb,
+		.user_data = user_data,
+		.done_sem = done_sem,
+		.done_info = done_info,
+		.bctx = beacon_ctx_acquire(),
+	};
+	memcpy(item.buf, buf, len);
+
+	int ret = k_msgq_put(&tx_msgq, &item, K_NO_WAIT);
+
+	if (ret < 0) {
+		LOG_WRN("tx_queue full, dropping frame (%zu bytes, err=%d)", len, ret);
+		beacon_ctx_release(item.bctx);
+	}
+	return ret;
+}
+
+int tx_queue_submit(const uint8_t *buf, size_t len, tx_queue_done_cb cb, void *user_data)
+{
+	return enqueue(buf, len, cb, user_data, NULL, NULL);
+}
+
+int tx_queue_submit_sync(const uint8_t *buf, size_t len, k_timeout_t timeout,
+			  struct tx_queue_done_info *out_info)
+{
+	struct k_sem done_sem;
+
+	k_sem_init(&done_sem, 0, 1);
+
+	int ret = enqueue(buf, len, NULL, NULL, &done_sem, out_info);
+
+	if (ret < 0) {
+		return ret;
+	}
+
+	ret = k_sem_take(&done_sem, timeout);
+	if (ret < 0) {
+		LOG_WRN("tx_queue_submit_sync: wait timed out (err=%d)", ret);
+	}
+	return ret;
+}

+ 103 - 0
src/relay/tx_queue.h

@@ -0,0 +1,103 @@
+/*
+ * Single TX callpoint for the whole app - relay (CBF forwards) and every
+ * other originator (Beacon, test MSG, future app payloads) all go through
+ * here instead of calling lora_send()/lora_recv_async() directly. Mirrors
+ * the RX side's single dispatch point in main.c's on_rx()/rx_work_handler:
+ * one place owns the pause-RX/TX/resume-RX sequence and the radio's TX
+ * state, so nothing else touches the radio to send.
+ *
+ * A real FIFO (k_msgq, TX_QUEUE_DEPTH items), drained by one dedicated
+ * thread - so callers don't block the radio behind their own encode/sign/
+ * etc. work (fire-and-forget: tx_queue_submit() just copies the frame in
+ * and returns), and so TX ordering across every caller stays strictly
+ * FIFO instead of racing across relay_work_q/beacon_work_q/payload_work_q.
+ * Deliberately small and un-prioritized for now - no quiet-period/duty-
+ * cycle gating yet, items are sent as fast as the thread can pull them
+ * off the queue. That gating (only TX during a tx-allowed window, hold
+ * off while RX is active) is planned to land inside this same single
+ * callpoint later, which is the whole reason every TX path is being
+ * routed through here now rather than later.
+ */
+#ifndef TX_QUEUE_H_
+#define TX_QUEUE_H_
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include <zephyr/device.h>
+#include <zephyr/drivers/lora.h>
+#include <zephyr/kernel.h>
+
+#define TX_QUEUE_DEPTH 8
+
+struct tx_queue_done_info {
+	int status; /* lora_send()'s return value: 0 = on air, <0 = failed */
+
+	/* One-shot IRQ timestamp peek (sx126x_irq_timestamp_get(),
+	 * SX126X_IRQ_TS_TX_DONE), taken immediately after lora_send()
+	 * returns, as a k_uptime_get()-comparable ms value - same
+	 * mechanism as main.c's rx_work.rx_done_ns/rx_done_ns_valid peek
+	 * and rx_ctx.h's own rx_done_uptime_ms. tx_done_valid=false
+	 * (tx_done_uptime_ms then just a k_uptime_get() fallback) if the
+	 * peek wasn't available.
+	 */
+	int64_t tx_done_uptime_ms;
+	bool tx_done_valid;
+};
+
+/* Runs on tx_queue's own thread, after the frame is on-air (or failed) -
+ * never on the submitting caller's context. Keep it short: it runs
+ * between draining one queue item and the next.
+ */
+typedef void (*tx_queue_done_cb)(void *user_data, const struct tx_queue_done_info *info);
+
+/* Pause/resume whatever RX mode main.c armed before this (continuous RX
+ * or CAD-triggered RX, see cad_rx.h) - tx_queue needs the radio idle for
+ * the duration of a send and listening again afterward, but has no
+ * business knowing which mode that means. rx_pause must leave the radio
+ * ready for an immediate lora_send()/sx126x_lora_send_lbt(); rx_resume
+ * re-arms whatever rx_pause paused.
+ */
+typedef void (*tx_queue_rx_pause_fn)(void);
+typedef void (*tx_queue_rx_resume_fn)(void);
+
+/* Must be called once at boot, after the LoRa device is configured and
+ * after the RX mode's own first arm call (lora_recv_async() or
+ * cad_rx_start()).
+ */
+void tx_queue_start(const struct device *lora_dev, tx_queue_rx_pause_fn rx_pause,
+		     tx_queue_rx_resume_fn rx_resume);
+
+/*
+ * Enqueues buf[0..len) for transmission and returns immediately (fire-
+ * and-forget) - buf is copied, so the caller's buffer is free to reuse
+ * the moment this returns. cb/user_data are optional (both NULL for no
+ * notification at all); when set, cb fires on tx_queue's own thread once
+ * the frame is actually on the air (or lora_send failed) - see
+ * tx_queue_done_cb.
+ *
+ * Returns 0 once queued, -EINVAL if len is too large to ever fit a
+ * frame, or a negative k_msgq_put() error (queue full) otherwise -
+ * callers should treat a full queue as backpressure (log and drop), not
+ * retry-forever; the queue is deliberately small.
+ */
+int tx_queue_submit(const uint8_t *buf, size_t len, tx_queue_done_cb cb, void *user_data);
+
+/*
+ * Blocking convenience for a caller with nothing else to do until its
+ * frame is actually sent: enqueues buf[0..len) and waits up to timeout
+ * for tx-done, filling *out_info (optional, may be NULL) before
+ * returning. Returns 0 on success, the enqueue error (see
+ * tx_queue_submit) if the queue was full, or -EAGAIN on timeout.
+ *
+ * Must not be called from tx_queue's own thread (self-deadlock, same
+ * hazard relay_work_q exists to avoid - see relay_work.h). On timeout,
+ * the item is still queued/in flight and will write *out_info later
+ * regardless - don't pass a timeout shorter than K_FOREVER unless
+ * out_info outlives that possibility (e.g. isn't a stack variable that's
+ * since gone out of scope).
+ */
+int tx_queue_submit_sync(const uint8_t *buf, size_t len, k_timeout_t timeout,
+			  struct tx_queue_done_info *out_info);
+
+#endif /* TX_QUEUE_H_ */

+ 81 - 0
src/rx/discovery_rx.c

@@ -0,0 +1,81 @@
+#include "rx/discovery_rx.h"
+
+#include <string.h>
+
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/atomic.h>
+
+#include "beacon/beacon_work.h"
+#include "frame/frame.h"
+#include "crypto/ident_stub.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+
+LOG_MODULE_REGISTER(discovery_rx, LOG_LEVEL_DISCOVERY_RX);
+
+/* Single-slot, same tradeoff as beacon_rx_work/main.c's rx_work. */
+static struct {
+	struct k_work work;
+	uint8_t payload[FRAME_MAX_PAYLOAD_LEN];
+	size_t payload_len;
+	bool mac_ok;
+	struct beacon_ctx_ref bctx;
+	atomic_t busy;
+} discovery_rx_work;
+
+static void discovery_rx_work_handler(struct k_work *work)
+{
+	ARG_UNUSED(work);
+
+	/* Stub: no real Discovery payload format exists yet (new-node
+	 * announce, pubkey-table version it's bootstrapping against, etc.) -
+	 * silent at INF until the real handler lands; DBG only for now.
+	 */
+	LOG_CTX_DBG("work", "payload_len=%zu mac_ok=%d", discovery_rx_work.payload_len,
+		    discovery_rx_work.mac_ok);
+
+	if (!discovery_rx_work.mac_ok) {
+		/* No real signature field/check exists yet - same
+		 * placeholder call beacon_rx.c makes, exercising the
+		 * bypass path ahead of real ident verify landing.
+		 */
+		uint8_t sig_placeholder[IDENT_SIG_LEN] = {0};
+		bool ident_ok = ident_verify_stub(discovery_rx_work.payload,
+						   discovery_rx_work.payload_len, sig_placeholder);
+
+		LOG_CTX_DBG("work/ident_stub", "ident_ok=%d", ident_ok);
+	}
+
+	beacon_ctx_release(discovery_rx_work.bctx);
+	atomic_set(&discovery_rx_work.busy, 0);
+}
+
+void discovery_rx_init(void)
+{
+	k_work_init(&discovery_rx_work.work, discovery_rx_work_handler);
+}
+
+void discovery_rx_submit(const struct rx_ctx *ctx)
+{
+	if (ctx->payload_len > sizeof(discovery_rx_work.payload)) {
+		LOG_ERR("Discovery payload too large to defer (%zu > %zu)", ctx->payload_len,
+			sizeof(discovery_rx_work.payload));
+		beacon_ctx_release(ctx->bctx);
+		return;
+	}
+
+	if (!atomic_cas(&discovery_rx_work.busy, 0, 1)) {
+		LOG_WRN("Discovery RX deferred work still busy, dropping (%zu bytes)",
+			ctx->payload_len);
+		beacon_ctx_release(ctx->bctx);
+		return;
+	}
+
+	memcpy(discovery_rx_work.payload, ctx->payload, ctx->payload_len);
+	discovery_rx_work.payload_len = ctx->payload_len;
+	discovery_rx_work.mac_ok = ctx->mac_ok;
+	discovery_rx_work.bctx = ctx->bctx;
+
+	k_work_submit_to_queue(&beacon_work_q, &discovery_rx_work.work);
+}

+ 42 - 0
src/rx/discovery_rx.h

@@ -0,0 +1,42 @@
+/*
+ * Hands a received Discovery payload off from relay_work_q to
+ * beacon_work_q - Discovery shares that queue with Beacon rather than
+ * getting its own, since both are network-control traffic and both will
+ * eventually touch ident/pubkey verification, not application payload
+ * work (that's payload_work.h's job). Same async, single-slot, drop-if-
+ * busy hand-off as beacon_rx.h.
+ *
+ * Stub for now: logs the frame and, when mac_ok is false, that it would
+ * fall back to an ident signature check - no real check exists yet
+ * (ident_verify_stub is a no-op), this just exercises the bypass path.
+ */
+#ifndef DISCOVERY_RX_H_
+#define DISCOVERY_RX_H_
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdbool.h>
+
+#include "frame/rx_ctx.h"
+
+/* Must be called once at boot, before any frame can arrive. */
+void discovery_rx_init(void);
+
+/*
+ * Copies ctx->payload[0..payload_len) and submits it to beacon_work_q.
+ * ctx->mac_ok reflects whether this frame's group MAC checked out -
+ * Discovery is the one PTYPE dispatched here even when it didn't (see
+ * main.c's rx dispatch), since a brand-new node has no group key yet
+ * and is meant to authenticate via ident signature instead.
+ *
+ * Takes ownership of ctx->bctx (see rx_ctx.h): released here, either
+ * immediately if this drops the frame, or at the end of the deferred
+ * work handler otherwise - unused by the handler body today (no real
+ * Discovery ident/pubkey check exists yet, see ident_stub.h), but
+ * threaded through now so the real check lands without re-plumbing
+ * this. ctx itself is only read, and only for the duration of this
+ * call - see rx_ctx.h's own transience note.
+ */
+void discovery_rx_submit(const struct rx_ctx *ctx);
+
+#endif /* DISCOVERY_RX_H_ */

+ 206 - 0
src/rx/payload_rx.c

@@ -0,0 +1,206 @@
+#include "rx/payload_rx.h"
+
+#include <string.h>
+
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+#include <zephyr/sys/atomic.h>
+#include <zephyr/sys/byteorder.h>
+
+#include "frame/frame.h"
+#include "log_ctx.h"
+#include "log_levels.h"
+#include "frame/msg_frame.h"
+#include "rx/payload_work.h"
+#include "relay/topology.h"
+#include "relay/tx_queue.h"
+
+LOG_MODULE_REGISTER(payload_rx, LOG_LEVEL_PAYLOAD_RX);
+
+/* Override with `west build ... -- -DEXTRA_CFLAGS=-DNODE_ID=1` per node -
+ * same build-wide definition main.c/tx_queue.c already use.
+ */
+#ifndef NODE_ID
+#define NODE_ID 0
+#endif
+
+/*
+ * Generates the single-slot work item + submit function for one stub
+ * PTYPE (still just logging arrival, no real handler yet). Only DATA/
+ * SECRET use this now - MSG has real sub-type dispatch below and no
+ * longer fits the one-size-stub shape.
+ */
+#define DEFINE_PAYLOAD_RX_STUB(name, label)                                                      \
+	static struct {                                                                          \
+		struct k_work work;                                                              \
+		uint8_t payload[FRAME_MAX_PAYLOAD_LEN];                                          \
+		size_t payload_len;                                                              \
+		struct beacon_ctx_ref bctx;                                                      \
+		atomic_t busy;                                                                   \
+	} name##_rx_work;                                                                        \
+                                                                                                  \
+	static void name##_rx_work_handler(struct k_work *work)                                 \
+	{                                                                                         \
+		ARG_UNUSED(work);                                                                \
+		/* Stub: buffering/reassembly not implemented yet - silent at   */              \
+		/* INF until a real handler lands, DBG only for now.            */              \
+		LOG_CTX_DBG("work", label " payload_len=%zu", name##_rx_work.payload_len);      \
+		beacon_ctx_release(name##_rx_work.bctx);                                         \
+		atomic_set(&name##_rx_work.busy, 0);                                             \
+	}                                                                                         \
+                                                                                                  \
+	void payload_rx_submit_##name(const struct rx_ctx *ctx)                                 \
+	{                                                                                         \
+		if (ctx->payload_len > sizeof(name##_rx_work.payload)) {                        \
+			LOG_ERR(label " payload too large to defer (%zu > %zu)",                \
+				ctx->payload_len, sizeof(name##_rx_work.payload));               \
+			beacon_ctx_release(ctx->bctx);                                           \
+			return;                                                                  \
+		}                                                                                 \
+		if (!atomic_cas(&name##_rx_work.busy, 0, 1)) {                                   \
+			LOG_WRN(label " RX deferred work still busy, dropping (%zu bytes)",     \
+				ctx->payload_len);                                               \
+			beacon_ctx_release(ctx->bctx);                                           \
+			return;                                                                  \
+		}                                                                                 \
+		memcpy(name##_rx_work.payload, ctx->payload, ctx->payload_len);                 \
+		name##_rx_work.payload_len = ctx->payload_len;                                  \
+		name##_rx_work.bctx = ctx->bctx;                                                 \
+		LOG_CTX_DBG("submit", label " payload_len=%zu", ctx->payload_len);              \
+		k_work_submit_to_queue(&payload_work_q, &name##_rx_work.work);                  \
+	}
+
+DEFINE_PAYLOAD_RX_STUB(data, "DATA")
+DEFINE_PAYLOAD_RX_STUB(secret, "SECRET")
+
+static uint16_t msg_tx_seq;
+
+static struct {
+	struct k_work work;
+	uint8_t payload[FRAME_MAX_PAYLOAD_LEN];
+	size_t payload_len;
+	struct beacon_ctx_ref bctx;
+	uint8_t src;
+	uint8_t dst;
+	atomic_t busy;
+} msg_rx_work;
+
+/* Replies to a Ping addressed to us: same 2 B data, Pong back to src -
+ * unicast, relay-requested like any other Msg (see msg_frame.h). Not a
+ * reply to whoever relayed the Ping to us (hdr.tx) - the reply is
+ * addressed to the Ping's origin (src) and routed there on its own
+ * merits, same as any other unicast.
+ */
+static void send_pong(uint8_t dst, const uint8_t *ping_data, size_t ping_data_len)
+{
+	uint8_t payload[MSG_SUBTYPE_HDR_LEN + MSG_PING_DATA_LEN];
+	size_t payload_len = msg_frame_encode(payload, MSG_SUBTYPE_PONG, ping_data, ping_data_len);
+
+	struct frame_hdr hdr = {
+		.ver = 0,
+		.dir = topology_dir(NODE_ID, dst),
+		.relay = FRAME_RELAY_REQUESTED,
+		.ptype = PTYPE_MSG,
+		.tx = NODE_ID,
+		.src = NODE_ID,
+		.dst = dst,
+		.seq = msg_tx_seq++,
+	};
+
+	uint8_t buf[FRAME_HDR_LEN + sizeof(payload) + GROUP_MAC_LEN];
+	int len = frame_encode(buf, sizeof(buf), &hdr, payload, payload_len);
+
+	if (len < 0) {
+		LOG_ERR("Pong encode failed: %d", len);
+		return;
+	}
+
+	int ret = tx_queue_submit(buf, (size_t)len, NULL, NULL);
+
+	if (ret < 0) {
+		LOG_ERR("Pong tx_queue_submit failed: %d", ret);
+	}
+}
+
+static void msg_rx_work_handler(struct k_work *work)
+{
+	ARG_UNUSED(work);
+
+	struct msg_frame frame;
+	int ret = msg_frame_decode(msg_rx_work.payload, msg_rx_work.payload_len, &frame);
+
+	LOG_CTX_DBG("work/decode", "payload_len=%zu ret=%d", msg_rx_work.payload_len, ret);
+
+	if (ret < 0) {
+		LOG_WRN("Msg decode failed (err=%d, len=%zu)", ret, msg_rx_work.payload_len);
+		beacon_ctx_release(msg_rx_work.bctx);
+		atomic_set(&msg_rx_work.busy, 0);
+		return;
+	}
+
+	LOG_CTX_DBG("work/dispatch", "subtype=0x%03x src=%u dst=%u data_len=%zu", frame.subtype,
+		    msg_rx_work.src, msg_rx_work.dst, frame.data_len);
+
+	switch (frame.subtype) {
+	case MSG_SUBTYPE_PING:
+		if (msg_rx_work.dst != NODE_ID) {
+			/* Not addressed to us - CBF may still relay it on
+			 * (that's cbf_on_rx(), already run before this
+			 * handler), but there's no reply for us to send.
+			 */
+			break;
+		}
+		if (frame.data_len != MSG_PING_DATA_LEN) {
+			LOG_WRN("Ping payload wrong size (data_len=%zu, want %d)", frame.data_len,
+				MSG_PING_DATA_LEN);
+			break;
+		}
+		LOG_INF("Ping from node %u, replying with Pong", msg_rx_work.src);
+		send_pong(msg_rx_work.src, frame.data, frame.data_len);
+		break;
+	case MSG_SUBTYPE_PONG:
+		LOG_INF("Pong from node %u", msg_rx_work.src);
+		break;
+	default:
+		LOG_WRN("Msg: unrecognized subtype=0x%03x from node %u", frame.subtype,
+			msg_rx_work.src);
+		break;
+	}
+
+	beacon_ctx_release(msg_rx_work.bctx);
+	atomic_set(&msg_rx_work.busy, 0);
+}
+
+void payload_rx_submit_msg(const struct rx_ctx *ctx)
+{
+	if (ctx->payload_len > sizeof(msg_rx_work.payload)) {
+		LOG_ERR("MSG payload too large to defer (%zu > %zu)", ctx->payload_len,
+			sizeof(msg_rx_work.payload));
+		beacon_ctx_release(ctx->bctx);
+		return;
+	}
+
+	if (!atomic_cas(&msg_rx_work.busy, 0, 1)) {
+		LOG_WRN("MSG RX deferred work still busy, dropping (%zu bytes)", ctx->payload_len);
+		beacon_ctx_release(ctx->bctx);
+		return;
+	}
+
+	memcpy(msg_rx_work.payload, ctx->payload, ctx->payload_len);
+	msg_rx_work.payload_len = ctx->payload_len;
+	msg_rx_work.bctx = ctx->bctx;
+	msg_rx_work.src = ctx->hdr->src;
+	msg_rx_work.dst = ctx->hdr->dst;
+
+	LOG_CTX_DBG("submit", "MSG payload_len=%zu src=%u dst=%u", ctx->payload_len,
+		    ctx->hdr->src, ctx->hdr->dst);
+
+	k_work_submit_to_queue(&payload_work_q, &msg_rx_work.work);
+}
+
+void payload_rx_init(void)
+{
+	k_work_init(&msg_rx_work.work, msg_rx_work_handler);
+	k_work_init(&data_rx_work.work, data_rx_work_handler);
+	k_work_init(&secret_rx_work.work, secret_rx_work_handler);
+}

+ 46 - 0
src/rx/payload_rx.h

@@ -0,0 +1,46 @@
+/*
+ * Hands received MSG/DATA/SECRET payloads off from relay_work_q to
+ * payload_work_q. Each PTYPE gets its own slot/work item (not one shared
+ * slot across all three) so a burst of, say, DATA chunks can't starve a
+ * MSG frame out of being deferred just because they happen to land close
+ * together - payload_work_q still only has one worker thread, so they
+ * run one at a time regardless, this only affects what gets dropped if
+ * two arrive before the first is even dequeued.
+ *
+ * DATA/SECRET are still stubs for now: each handler just logs that a
+ * frame arrived. The real per-type handlers (their own buffering/
+ * reassembly across multiple calls, SECRET eventually decrypting)
+ * replace these handler bodies later - the RX layer calling them once
+ * per received frame, and leaving reassembly state internal to the
+ * handler, doesn't change.
+ *
+ * MSG decodes msg_frame.h's sub-type header and dispatches by sub-type
+ * (today: just Ping/Pong) - see payload_rx.c.
+ */
+#ifndef PAYLOAD_RX_H_
+#define PAYLOAD_RX_H_
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "frame/rx_ctx.h"
+
+/* Must be called once at boot, before any frame can arrive. */
+void payload_rx_init(void);
+
+/*
+ * Each takes ownership of ctx->bctx (see rx_ctx.h): released either
+ * immediately if this drops the frame, or at the end of the deferred
+ * work handler otherwise. ctx itself is only read, and only for the
+ * duration of the call - see rx_ctx.h's own transience note.
+ *
+ * payload_rx_submit_msg() reads ctx->hdr->src/dst: a sub-type handler
+ * needs to know who to reply to (src) and whether this frame is even
+ * addressed to this node (dst) to decide that. DATA/SECRET don't use
+ * ctx->hdr yet (still stubs).
+ */
+void payload_rx_submit_msg(const struct rx_ctx *ctx);
+void payload_rx_submit_data(const struct rx_ctx *ctx);
+void payload_rx_submit_secret(const struct rx_ctx *ctx);
+
+#endif /* PAYLOAD_RX_H_ */

+ 27 - 0
src/rx/payload_work.c

@@ -0,0 +1,27 @@
+#include "rx/payload_work.h"
+
+/* Stub handlers today just log; sized ahead of need the same way
+ * beacon_work_q is, since DATA/SECRET are expected to grow real
+ * buffering/reassembly and SECRET eventually real crypto.
+ */
+#define PAYLOAD_WORK_STACK_SIZE 4096
+
+/* Same reasoning as BEACON_WORK_PRIORITY (see beacon_work.c): strictly
+ * below RELAY_WORK_PRIORITY (-1) so relay_work_q's frame decode/group-
+ * MAC/CBF forwarding is never kept waiting by payload processing. Equal
+ * to beacon_work_q's own priority - nothing here needs to outrank
+ * control-plane (beacon/discovery) traffic or vice versa, just to stay
+ * out of relay's way.
+ */
+#define PAYLOAD_WORK_PRIORITY 0
+
+struct k_work_q payload_work_q;
+static K_THREAD_STACK_DEFINE(payload_work_stack, PAYLOAD_WORK_STACK_SIZE);
+
+void payload_work_q_start(void)
+{
+	k_work_queue_start(&payload_work_q, payload_work_stack,
+			   K_THREAD_STACK_SIZEOF(payload_work_stack),
+			   PAYLOAD_WORK_PRIORITY, NULL);
+	k_thread_name_set(k_work_queue_thread_get(&payload_work_q), "payload_work");
+}

+ 22 - 0
src/rx/payload_work.h

@@ -0,0 +1,22 @@
+/*
+ * A workqueue dedicated to application payload frames (PTYPE_MSG/DATA/
+ * SECRET) - kept separate from both relay_work_q (frame decode/group-MAC/
+ * CBF forwarding) and beacon_work_q (beacon/discovery control-plane
+ * decode + ident verify), so network relay, network control, and network
+ * payloads stay three isolated execution contexts: heavy or slow payload
+ * handling (DATA/SECRET reassembly, eventually real crypto on SECRET)
+ * can never starve radio-adjacent relay timing or beacon/discovery
+ * processing, and vice versa. See "workflow design" discussion in
+ * project notes.
+ */
+#ifndef PAYLOAD_WORK_H_
+#define PAYLOAD_WORK_H_
+
+#include <zephyr/kernel.h>
+
+/* Must be called once at boot, before scheduling anything onto it. */
+void payload_work_q_start(void);
+
+extern struct k_work_q payload_work_q;
+
+#endif /* PAYLOAD_WORK_H_ */

+ 221 - 0
zephyr-patches/0001-drivers-lora-sx126x-add-one-shot-IRQ-timestamp-peek.patch

@@ -0,0 +1,221 @@
+From 4a7e9a77611d7a85a660e826c1dad97f355b0414 Mon Sep 17 00:00:00 2001
+From: ZiB <zib@zi3.xyz>
+Date: Mon, 2 Nov 2026 00:00:11 -0800
+Subject: [PATCH 1/3] drivers: lora: sx126x: add one-shot IRQ timestamp peek
+
+Expose higher-precision IRQ timestamp via sx126x_irq_timestamp_get()
+by capturing a free-running cycle count at the DIO1 GPIO ISR, before
+any SPI/workqueue latency, and resolving it in the IRQ handler to an
+absolute timestamp directly comparable to k_uptime_get()/k_uptime_ticks().
+
+Gated by CONFIG_LORA_SX126X_NATIVE_IRQ_TIMESTAMP (default y): the
+cycle-counter/uptime reads, the conversions, and the extra struct
+fields all compile out when disabled, and sx126x_irq_timestamp_get()
+still links as a trivial false-returning stub so no caller needs its
+own #ifdef. The added work (one more cycle-counter read in the ISR,
+one k_uptime_ticks() read, two 64-bit fixed-point conversions) runs
+per IRQ, reducing timestamp latency by about 450us in testing.
+
+Signed-off-by: ZiB <zib@zi3.xyz>
+---
+ drivers/lora/native/sx126x/Kconfig      | 10 +++++
+ drivers/lora/native/sx126x/sx126x.c     | 38 +++++++++++++++++
+ drivers/lora/native/sx126x/sx126x.h     |  7 ++++
+ drivers/lora/native/sx126x/sx126x_hal.c |  4 ++
+ drivers/lora/native/sx126x/sx126x_hal.h |  3 ++
+ include/zephyr/drivers/lora/sx126x.h    | 55 +++++++++++++++++++++++++
+ 6 files changed, 117 insertions(+)
+ create mode 100644 include/zephyr/drivers/lora/sx126x.h
+
+diff --git a/drivers/lora/native/sx126x/Kconfig b/drivers/lora/native/sx126x/Kconfig
+index 2e59112419a..14d955302b3 100644
+--- a/drivers/lora/native/sx126x/Kconfig
++++ b/drivers/lora/native/sx126x/Kconfig
+@@ -39,3 +39,13 @@ config LORA_SX126X_NATIVE_STANDALONE
+ 	depends on DT_HAS_SEMTECH_SX1262_ENABLED || DT_HAS_SEMTECH_SX1261_ENABLED
+ 	help
+ 	  Standalone SX1261/SX1262 support for the native SX126x driver.
++
++config LORA_SX126X_NATIVE_IRQ_TIMESTAMP
++	bool "Radio-IRQ timestamp peek"
++	default y
++	depends on LORA_SX126X_NATIVE
++	help
++	  Expose higher-precision IRQ timestamp via sx126x_irq_timestamp_get()
++	  by capturing a free-running cycle count at the DIO1 GPIO ISR, before
++	  any SPI/workqueue latency, and resolve it in the IRQ handler to an
++	  absolute timestamp directly comparable to k_uptime_get()/k_uptime_ticks().
+diff --git a/drivers/lora/native/sx126x/sx126x.c b/drivers/lora/native/sx126x/sx126x.c
+index d3a7313e964..c3f9d63f3f5 100644
+--- a/drivers/lora/native/sx126x/sx126x.c
++++ b/drivers/lora/native/sx126x/sx126x.c
+@@ -6,6 +6,7 @@
+ #include <zephyr/kernel.h>
+ #include <zephyr/device.h>
+ #include <zephyr/drivers/lora.h>
++#include <zephyr/drivers/lora/sx126x.h>
+ #include <zephyr/pm/device.h>
+ #include <zephyr/sys/byteorder.h>
+ 
+@@ -847,6 +848,15 @@ static void sx126x_irq_work_handler(struct k_work *work)
+ 		return;
+ 	}
+ 
++#if IS_ENABLED(CONFIG_LORA_SX126X_NATIVE_IRQ_TIMESTAMP)
++	uint32_t delta_cycles = k_cycle_get_32() - data->hal.dio1_raw_cycles;
++	uint64_t delta_ns = k_cyc_to_ns_floor64(delta_cycles);
++	uint64_t now_ns = k_ticks_to_ns_floor64(k_uptime_ticks());
++
++	data->irq_ts.event_uptime_ns = now_ns - delta_ns;
++	data->irq_ts.irq_status = irq_status;
++#endif
++
+ 	LOG_DBG("IRQ status: 0x%04x", irq_status);
+ 
+ 	/* Clear handled IRQs */
+@@ -870,6 +880,34 @@ static void sx126x_irq_work_handler(struct k_work *work)
+ 	}
+ }
+ 
++bool sx126x_irq_timestamp_get(const struct device *dev, uint16_t irq_bit, uint64_t *ns_out)
++{
++#if IS_ENABLED(CONFIG_LORA_SX126X_NATIVE_IRQ_TIMESTAMP)
++	struct sx126x_data *data = dev->data;
++	unsigned int key;
++	uint64_t event_uptime_ns;
++	uint16_t irq_status;
++
++	key = irq_lock();
++	event_uptime_ns = data->irq_ts.event_uptime_ns;
++	irq_status = data->irq_ts.irq_status;
++	irq_unlock(key);
++
++	if (!(irq_status & irq_bit)) {
++		return false;
++	}
++
++	*ns_out = event_uptime_ns;
++
++	return true;
++#else
++	ARG_UNUSED(dev);
++	ARG_UNUSED(irq_bit);
++	ARG_UNUSED(ns_out);
++	return false;
++#endif
++}
++
+ static int sx126x_set_configured_packet_params(const struct device *dev, uint8_t payload_len)
+ {
+ 	struct sx126x_data *data = dev->data;
+diff --git a/drivers/lora/native/sx126x/sx126x.h b/drivers/lora/native/sx126x/sx126x.h
+index 07c50f0044d..0ea5154e901 100644
+--- a/drivers/lora/native/sx126x/sx126x.h
++++ b/drivers/lora/native/sx126x/sx126x.h
+@@ -75,6 +75,13 @@ struct sx126x_data {
+ 	/* Deferred work for interrupt handling */
+ 	struct k_work irq_work;
+ 	const struct device *dev;
++
++#if IS_ENABLED(CONFIG_LORA_SX126X_NATIVE_IRQ_TIMESTAMP)
++	struct {
++		uint64_t event_uptime_ns;
++		uint16_t irq_status;
++	} irq_ts;
++#endif
+ };
+ 
+ int sx126x_set_packet_type(const struct device *dev, uint8_t type);
+diff --git a/drivers/lora/native/sx126x/sx126x_hal.c b/drivers/lora/native/sx126x/sx126x_hal.c
+index 34f7089584f..2bc86251fc0 100644
+--- a/drivers/lora/native/sx126x/sx126x_hal.c
++++ b/drivers/lora/native/sx126x/sx126x_hal.c
+@@ -76,6 +76,10 @@ static void dio1_isr(const struct device *gpio, struct gpio_callback *cb,
+ {
+ 	struct sx126x_hal_data *data = CONTAINER_OF(cb, struct sx126x_hal_data, dio1_cb);
+ 
++#if IS_ENABLED(CONFIG_LORA_SX126X_NATIVE_IRQ_TIMESTAMP)
++	data->dio1_raw_cycles = k_cycle_get_32();
++#endif
++
+ 	if (data->dio1_callback != NULL) {
+ 		data->dio1_callback(data->dev);
+ 	}
+diff --git a/drivers/lora/native/sx126x/sx126x_hal.h b/drivers/lora/native/sx126x/sx126x_hal.h
+index e4b26ba60bd..b7d8d30922e 100644
+--- a/drivers/lora/native/sx126x/sx126x_hal.h
++++ b/drivers/lora/native/sx126x/sx126x_hal.h
+@@ -42,6 +42,9 @@ struct sx126x_hal_data {
+ 	struct gpio_callback dio1_cb;
+ 	void (*dio1_callback)(const struct device *dev);
+ 	const struct device *dev;
++#if IS_ENABLED(CONFIG_LORA_SX126X_NATIVE_IRQ_TIMESTAMP)
++	uint32_t dio1_raw_cycles;
++#endif
+ };
+ 
+ int sx126x_hal_init(const struct device *dev);
+diff --git a/include/zephyr/drivers/lora/sx126x.h b/include/zephyr/drivers/lora/sx126x.h
+new file mode 100644
+index 00000000000..7e134f434af
+--- /dev/null
++++ b/include/zephyr/drivers/lora/sx126x.h
+@@ -0,0 +1,55 @@
++/**
++ * @file
++ * @brief SX126x-specific extensions to the LoRa driver API.
++ *
++ * Not part of the generic @ref lora_interface API: these expose a hardware
++ * capability (radio-internal IRQ timing) specific to the SX126x family.
++ */
++#ifndef ZEPHYR_INCLUDE_DRIVERS_LORA_SX126X_H_
++#define ZEPHYR_INCLUDE_DRIVERS_LORA_SX126X_H_
++
++#include <zephyr/device.h>
++#include <zephyr/sys/util.h>
++#include <stdint.h>
++#include <stdbool.h>
++
++#ifdef __cplusplus
++extern "C" {
++#endif
++
++/**
++ * @name SX126x IRQ source bits
++ *
++ * Mirror the chip's GetIrqStatus bit positions (DS.SX1261-2.W.APP,
++ * 13.3.1), for use with @ref sx126x_irq_timestamp_get.
++ * @{
++ */
++#define SX126X_IRQ_TS_TX_DONE           BIT(0)
++#define SX126X_IRQ_TS_RX_DONE           BIT(1)
++#define SX126X_IRQ_TS_PREAMBLE_DETECTED BIT(2)
++#define SX126X_IRQ_TS_SYNC_WORD_VALID   BIT(3)
++#define SX126X_IRQ_TS_HEADER_VALID      BIT(4)
++#define SX126X_IRQ_TS_HEADER_ERR        BIT(5)
++#define SX126X_IRQ_TS_CRC_ERR           BIT(6)
++#define SX126X_IRQ_TS_RX_TX_TIMEOUT     BIT(9)
++/** @} */
++
++/**
++ * @brief Get a precise timestamp for the most recent occurrence of one
++ * SX126x radio IRQ.
++ *
++ * @param dev      SX126x LoRa device
++ * @param irq_bit  Single @c SX126X_IRQ_TS_* bit to query
++ * @param ns_out   Set to the absolute event timestamp, in nanoseconds,
++ *                 on the same timebase as @ref k_uptime_ticks
++ * @return true if @p irq_bit was set in the most recent IRQ status read,
++ *         and @p ns_out was written; false otherwise (including when
++ *         @kconfig{CONFIG_LORA_SX126X_NATIVE_IRQ_TIMESTAMP} is disabled)
++ */
++bool sx126x_irq_timestamp_get(const struct device *dev, uint16_t irq_bit, uint64_t *ns_out);
++
++#ifdef __cplusplus
++}
++#endif
++
++#endif /* ZEPHYR_INCLUDE_DRIVERS_LORA_SX126X_H_ */
+-- 
+2.54.0 (Apple Git-157)
+

+ 467 - 0
zephyr-patches/0002-drivers-lora-native-sx126x-implement-Channel-Activit.patch

@@ -0,0 +1,467 @@
+From 4aa014031d7bacdc08e2b5d5e1cfadbf0c5a964d Mon Sep 17 00:00:00 2001
+From: ZiB <zib@zi3.xyz>
+Date: Sat, 7 Nov 2026 03:35:03 -0800
+Subject: [PATCH 2/3] drivers: lora: native: sx126x: implement Channel Activity
+ Detection
+
+Wires the sx126x native driver into the lora.h CAD API that was
+already fully specified there (lora_cad()/lora_cad_async(),
+lora_cad_mode, and the cad sub-struct in lora_modem_config) but never
+had a backend: .cad/.cad_async were both unset in the driver's
+DEVICE_API struct.
+
+Adds sx126x_set_cad_params()/sx126x_set_cad(), a new SX126X_STATE_CAD
+state following the existing CAS-based busy protocol, and a
+CadDone/CadActivityDetected IRQ path alongside the existing TX/RX/
+timeout ones - folded into SX126X_IRQ_MASK_REQUIRED since those bits
+only ever fire after a SetCAD.
+
+Signed-off-by: ZiB <zib@zi3.xyz>
+---
+ drivers/lora/native/sx126x/sx126x.c      | 284 ++++++++++++++++++++++-
+ drivers/lora/native/sx126x/sx126x.h      |  13 ++
+ drivers/lora/native/sx126x/sx126x_regs.h |  11 +
+ include/zephyr/drivers/lora/sx126x.h     |  21 +-
+ 4 files changed, 317 insertions(+), 12 deletions(-)
+
+diff --git a/drivers/lora/native/sx126x/sx126x.c b/drivers/lora/native/sx126x/sx126x.c
+index c3f9d63f3f5..8ba8416ed0c 100644
+--- a/drivers/lora/native/sx126x/sx126x.c
++++ b/drivers/lora/native/sx126x/sx126x.c
+@@ -22,6 +22,11 @@ LOG_MODULE_REGISTER(sx126x, CONFIG_LORA_LOG_LEVEL);
+ 
+ #define SX126X_SF56_MIN_PREAMBLE_LEN	12
+ 
++/* IRQ sources the driver's own TX/RX/CAD/timeout handling needs on DIO1. */
++#define SX126X_IRQ_MASK_REQUIRED \
++	(SX126X_IRQ_TX_DONE | SX126X_IRQ_RX_DONE | SX126X_IRQ_RX_TX_TIMEOUT | SX126X_IRQ_CRC_ERR | \
++	 SX126X_IRQ_CAD_DONE | SX126X_IRQ_CAD_ACTIVITY_DETECTED)
++
+ #ifdef CONFIG_LORA_GFSK
+ #define SX126X_IS_GFSK(data) ((data)->gfsk)
+ #else
+@@ -406,6 +411,102 @@ static int sx126x_set_rx(const struct device *dev, uint32_t timeout_ms)
+ 	return sx126x_hal_write_cmd(dev, SX126X_CMD_SET_RX, buf, 3);
+ }
+ 
++static int sx126x_cad_symbol_num_to_reg(enum lora_cad_symbol_num symbol_num, uint8_t *reg)
++{
++	switch (symbol_num) {
++	case LORA_CAD_SYMB_1:
++		*reg = SX126X_CAD_ON_1_SYMB;
++		return 0;
++	case LORA_CAD_SYMB_2:
++		*reg = SX126X_CAD_ON_2_SYMB;
++		return 0;
++	case LORA_CAD_SYMB_4:
++		*reg = SX126X_CAD_ON_4_SYMB;
++		return 0;
++	case LORA_CAD_SYMB_8:
++		*reg = SX126X_CAD_ON_8_SYMB;
++		return 0;
++	case LORA_CAD_SYMB_16:
++		*reg = SX126X_CAD_ON_16_SYMB;
++		return 0;
++	default:
++		return -EINVAL;
++	}
++}
++
++/*
++ * Per-SF detDetMin/cadDetPeak/cadSymbolNum defaults, from Semtech AN1200.48
++ * ("SX126x CAD Performance Evaluation") Tables 1 and 43 - the "best
++ * settings" rows, which pick the symbol count each SF needs for reliable
++ * detection without false positives, rather than the lower-power 2-symbol
++ * alternative also published there. AN1200.48 only characterizes 125kHz
++ * and 500kHz; a config using any other bandwidth must supply explicit
++ * cad.detection_peak/detection_minimum (and usually symbol_num) of its own.
++ */
++struct sx126x_cad_defaults {
++	uint8_t det_min;
++	uint8_t det_peak;
++	enum lora_cad_symbol_num symbol_num;
++};
++
++static int sx126x_cad_lookup_defaults(enum lora_signal_bandwidth bandwidth,
++				      enum lora_datarate datarate,
++				      struct sx126x_cad_defaults *out)
++{
++	static const struct sx126x_cad_defaults bw_125khz[] = {
++		[SF_7]  = { 10, 22, LORA_CAD_SYMB_2 },
++		[SF_8]  = { 10, 22, LORA_CAD_SYMB_2 },
++		[SF_9]  = { 10, 23, LORA_CAD_SYMB_4 },
++		[SF_10] = { 10, 24, LORA_CAD_SYMB_4 },
++		[SF_11] = { 10, 25, LORA_CAD_SYMB_4 },
++		[SF_12] = { 10, 28, LORA_CAD_SYMB_4 },
++	};
++	static const struct sx126x_cad_defaults bw_500khz[] = {
++		[SF_7]  = { 10, 21, LORA_CAD_SYMB_4 },
++		[SF_8]  = { 10, 22, LORA_CAD_SYMB_4 },
++		[SF_9]  = { 10, 22, LORA_CAD_SYMB_4 },
++		[SF_10] = { 10, 23, LORA_CAD_SYMB_4 },
++		[SF_11] = { 10, 25, LORA_CAD_SYMB_4 },
++		[SF_12] = { 10, 29, LORA_CAD_SYMB_8 },
++	};
++	const struct sx126x_cad_defaults *table;
++
++	if (bandwidth == BW_125_KHZ) {
++		table = bw_125khz;
++	} else if (bandwidth == BW_500_KHZ) {
++		table = bw_500khz;
++	} else {
++		return -ENOTSUP;
++	}
++
++	if (datarate < SF_7 || datarate > SF_12) {
++		return -EINVAL;
++	}
++
++	*out = table[datarate];
++	return 0;
++}
++
++static int sx126x_set_cad_params(const struct device *dev, uint8_t symbol_num_reg,
++				 uint8_t det_peak, uint8_t det_min, uint8_t exit_mode,
++				 uint32_t timeout)
++{
++	uint8_t buf[7];
++
++	buf[0] = symbol_num_reg;
++	buf[1] = det_peak;
++	buf[2] = det_min;
++	buf[3] = exit_mode;
++	sys_put_be24(timeout, &buf[4]);
++
++	return sx126x_hal_write_cmd(dev, SX126X_CMD_SET_CAD_PARAMS, buf, 7);
++}
++
++static int sx126x_set_cad(const struct device *dev)
++{
++	return sx126x_hal_write_cmd(dev, SX126X_CMD_SET_CAD, NULL, 0);
++}
++
+ static int sx126x_get_rx_buffer_status(const struct device *dev,
+ 				       uint8_t *payload_len, uint8_t *offset)
+ {
+@@ -558,9 +659,9 @@ static int sx126x_chip_init(const struct device *dev)
+ 		return ret;
+ 	}
+ 
+-	/* Configure IRQs on DIO1: TX done, RX done, timeout */
+-	uint16_t irq_mask = SX126X_IRQ_TX_DONE | SX126X_IRQ_RX_DONE |
+-			    SX126X_IRQ_RX_TX_TIMEOUT | SX126X_IRQ_CRC_ERR;
++	/* Configure IRQs on DIO1: TX done, RX done, CAD done, timeout */
++	uint16_t irq_mask = SX126X_IRQ_MASK_REQUIRED;
++
+ 	ret = sx126x_set_dio_irq_params(dev, irq_mask, irq_mask, 0, 0);
+ 	if (ret < 0) {
+ 		LOG_ERR("Set IRQ params failed: %d", ret);
+@@ -804,6 +905,33 @@ out:
+ 	}
+ }
+ 
++static void sx126x_handle_irq_cad_done(const struct device *dev, uint16_t irq_status)
++{
++	struct sx126x_data *data = dev->data;
++	struct sx126x_cad_result result = {
++		.activity_detected = (irq_status & SX126X_IRQ_CAD_ACTIVITY_DETECTED) != 0,
++	};
++
++	LOG_DBG("CAD done: activity=%d", result.activity_detected);
++
++	/* CAD_ONLY already left the chip in STDBY_RC; this just folds it
++	 * back into the driver's own rest state.
++	 */
++	sx126x_set_sleep(dev);
++
++	if (data->cad_cb != NULL) {
++		lora_cad_cb cb = data->cad_cb;
++		void *user_data = data->cad_cb_user_data;
++
++		data->cad_cb = NULL;
++		data->cad_cb_user_data = NULL;
++		cb(dev, result.activity_detected, user_data);
++		return;
++	}
++
++	k_msgq_put(&data->cad_msgq, &result, K_NO_WAIT);
++}
++
+ static void sx126x_handle_irq_timeout(const struct device *dev)
+ {
+ 	struct sx126x_data *data = dev->data;
+@@ -874,6 +1002,10 @@ static void sx126x_irq_work_handler(struct k_work *work)
+ 		sx126x_handle_irq_timeout(dev);
+ 	}
+ 
++	if (irq_status & SX126X_IRQ_CAD_DONE) {
++		sx126x_handle_irq_cad_done(dev, irq_status);
++	}
++
+ 	/* Re-enable the DIO1 interrupt for the next event (unless sleeping) */
+ 	if (atomic_get(&data->state) != SX126X_REST_STATE) {
+ 		sx126x_hal_dio1_irq_enable(dev);
+@@ -1573,6 +1705,148 @@ static int sx126x_lora_rssi(const struct device *dev, int16_t *rssi)
+ 	return ret;
+ }
+ 
++/*
++ * Shared setup for lora_cad()/lora_cad_async(): resolve CAD parameters,
++ * claim the radio and issue SetCadParams + SetCAD. On return 0, the SPI
++ * transaction is complete and the chip is already searching; the caller's
++ * msgq/cb delivers the CadDone result. On error, state and mutex are
++ * already unwound.
++ */
++static int sx126x_cad_start(const struct device *dev)
++{
++	struct sx126x_data *data = dev->data;
++	struct sx126x_cad_defaults defaults;
++	enum lora_cad_symbol_num symbol_num;
++	uint8_t det_peak, det_min, symbol_num_reg;
++	int ret;
++
++	if (!data->config_valid) {
++		LOG_ERR("Not configured");
++		return -EINVAL;
++	}
++
++	if (SX126X_IS_GFSK(data)) {
++		LOG_ERR("CAD is a LoRa-only operation");
++		return -ENOTSUP;
++	}
++
++	symbol_num = data->config.cad.symbol_num;
++	det_peak = data->config.cad.detection_peak;
++	det_min = data->config.cad.detection_minimum;
++
++	if (symbol_num == 0 || det_peak == 0 || det_min == 0) {
++		ret = sx126x_cad_lookup_defaults(data->config.bandwidth,
++						 data->config.datarate, &defaults);
++		if (ret < 0) {
++			LOG_ERR("No CAD defaults for this bandwidth/SF; "
++				"set cad.symbol_num/detection_peak/detection_minimum explicitly");
++			return ret;
++		}
++		if (symbol_num == 0) {
++			symbol_num = defaults.symbol_num;
++		}
++		if (det_peak == 0) {
++			det_peak = defaults.det_peak;
++		}
++		if (det_min == 0) {
++			det_min = defaults.det_min;
++		}
++	}
++
++	ret = sx126x_cad_symbol_num_to_reg(symbol_num, &symbol_num_reg);
++	if (ret < 0) {
++		LOG_ERR("Invalid CAD symbol count: %d", symbol_num);
++		return ret;
++	}
++
++	if (!atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_CAD)) {
++		LOG_ERR("Busy");
++		return -EBUSY;
++	}
++
++	k_mutex_lock(&data->lock, K_FOREVER);
++
++	ret = sx126x_ensure_ready(dev);
++	if (ret < 0) {
++		goto out_error;
++	}
++
++	ret = sx126x_set_cad_params(dev, symbol_num_reg, det_peak, det_min,
++				    SX126X_CAD_EXIT_MODE_CAD_ONLY, 0);
++	if (ret < 0) {
++		goto out_error;
++	}
++
++	ret = sx126x_set_cad(dev);
++	if (ret < 0) {
++		goto out_error;
++	}
++
++	k_mutex_unlock(&data->lock);
++	return 0;
++
++out_error:
++	sx126x_set_sleep(dev);
++	k_mutex_unlock(&data->lock);
++	return ret;
++}
++
++static int sx126x_lora_cad(const struct device *dev, k_timeout_t timeout)
++{
++	struct sx126x_data *data = dev->data;
++	struct sx126x_cad_result result;
++	int ret;
++
++	k_msgq_purge(&data->cad_msgq);
++
++	ret = sx126x_cad_start(dev);
++	if (ret < 0) {
++		return ret;
++	}
++
++	ret = k_msgq_get(&data->cad_msgq, &result, timeout);
++	if (ret < 0) {
++		LOG_DBG("CAD timeout");
++		k_mutex_lock(&data->lock, K_FOREVER);
++		sx126x_set_standby(dev, SX126X_STANDBY_RC);
++		sx126x_set_sleep(dev);
++		k_mutex_unlock(&data->lock);
++		return -ETIMEDOUT;
++	}
++
++	return result.activity_detected ? 1 : 0;
++}
++
++static int sx126x_lora_cad_async(const struct device *dev, lora_cad_cb cb, void *user_data)
++{
++	struct sx126x_data *data = dev->data;
++	int ret;
++
++	if (cb == NULL) {
++		k_mutex_lock(&data->lock, K_FOREVER);
++		data->cad_cb = NULL;
++		data->cad_cb_user_data = NULL;
++		if (atomic_cas(&data->state, SX126X_STATE_CAD, SX126X_STATE_IDLE)) {
++			sx126x_set_standby(dev, SX126X_STANDBY_RC);
++			sx126x_set_sleep(dev);
++		}
++		k_mutex_unlock(&data->lock);
++		return 0;
++	}
++
++	data->cad_cb = cb;
++	data->cad_cb_user_data = user_data;
++
++	ret = sx126x_cad_start(dev);
++	if (ret < 0) {
++		data->cad_cb = NULL;
++		data->cad_cb_user_data = NULL;
++		return ret;
++	}
++
++	return 0;
++}
++
+ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency,
+ 			       int8_t tx_power, uint16_t duration)
+ {
+@@ -1644,6 +1918,8 @@ static DEVICE_API(lora, sx126x_lora_api) = {
+ 	.airtime = sx126x_lora_airtime,
+ 	.test_cw = sx126x_lora_test_cw,
+ 	.rssi = sx126x_lora_rssi,
++	.cad = sx126x_lora_cad,
++	.cad_async = sx126x_lora_cad_async,
+ };
+ 
+ #ifdef CONFIG_PM_DEVICE
+@@ -1673,6 +1949,8 @@ static int sx126x_init(const struct device *dev)
+ 		    sizeof(struct sx126x_tx_result), 1);
+ 	k_msgq_init(&data->rx_msgq, (char *)&data->rx_result,
+ 		    sizeof(struct sx126x_rx_result), 1);
++	k_msgq_init(&data->cad_msgq, (char *)&data->cad_result,
++		    sizeof(struct sx126x_cad_result), 1);
+ 	k_work_init(&data->irq_work, sx126x_irq_work_handler);
+ 	data->dev = dev;
+ 	atomic_set(&data->state, SX126X_STATE_IDLE);
+diff --git a/drivers/lora/native/sx126x/sx126x.h b/drivers/lora/native/sx126x/sx126x.h
+index 0ea5154e901..5f22f620f35 100644
+--- a/drivers/lora/native/sx126x/sx126x.h
++++ b/drivers/lora/native/sx126x/sx126x.h
+@@ -19,6 +19,7 @@ enum sx126x_state {
+ 	SX126X_STATE_TX,
+ 	SX126X_STATE_RX,
+ 	SX126X_STATE_RX_DUTY_CYCLE,
++	SX126X_STATE_CAD,
+ };
+ 
+ struct sx126x_tx_result {
+@@ -32,6 +33,10 @@ struct sx126x_rx_result {
+ 	int status;
+ };
+ 
++struct sx126x_cad_result {
++	bool activity_detected;
++};
++
+ struct sx126x_data {
+ 	struct sx126x_hal_data hal;
+ 
+@@ -63,6 +68,14 @@ struct sx126x_data {
+ 	lora_recv_cb rx_cb;
+ 	void *rx_cb_user_data;
+ 
++	/* CAD completion via message queue */
++	struct k_msgq cad_msgq;
++	struct sx126x_cad_result cad_result;
++
++	/* Async CAD callback */
++	lora_cad_cb cad_cb;
++	void *cad_cb_user_data;
++
+ 	/* RX duty cycle parameters (in SX126x timeout ticks) */
+ 	struct {
+ 		uint32_t rx_period;
+diff --git a/drivers/lora/native/sx126x/sx126x_regs.h b/drivers/lora/native/sx126x/sx126x_regs.h
+index 9b342f473f3..819ef81b2fd 100644
+--- a/drivers/lora/native/sx126x/sx126x_regs.h
++++ b/drivers/lora/native/sx126x/sx126x_regs.h
+@@ -201,6 +201,17 @@
+ #define SX126X_LORA_IQ_STANDARD             0x00
+ #define SX126X_LORA_IQ_INVERTED             0x01
+ 
++/* CAD symbol count (for SET_CAD_PARAMS) */
++#define SX126X_CAD_ON_1_SYMB                0x00
++#define SX126X_CAD_ON_2_SYMB                0x01
++#define SX126X_CAD_ON_4_SYMB                0x02
++#define SX126X_CAD_ON_8_SYMB                0x03
++#define SX126X_CAD_ON_16_SYMB               0x04
++
++/* CAD exit mode (for SET_CAD_PARAMS) */
++#define SX126X_CAD_EXIT_MODE_CAD_ONLY       0x00
++#define SX126X_CAD_EXIT_MODE_CAD_RX         0x01
++
+ /* TX Power Ramp Time (for SET_TX_PARAMS) */
+ #define SX126X_RAMP_10_US                   0x00
+ #define SX126X_RAMP_20_US                   0x01
+diff --git a/include/zephyr/drivers/lora/sx126x.h b/include/zephyr/drivers/lora/sx126x.h
+index 7e134f434af..4052f86aa41 100644
+--- a/include/zephyr/drivers/lora/sx126x.h
++++ b/include/zephyr/drivers/lora/sx126x.h
+@@ -21,17 +21,20 @@ extern "C" {
+  * @name SX126x IRQ source bits
+  *
+  * Mirror the chip's GetIrqStatus bit positions (DS.SX1261-2.W.APP,
+- * 13.3.1), for use with @ref sx126x_irq_timestamp_get.
++ * 13.3.1), for use with @ref sx126x_irq_timestamp_get. Limited to the
++ * sources always enabled on DIO1 (SX126X_IRQ_MASK_REQUIRED in the
++ * driver) - the finer-grained demod sub-stages (preamble detected, sync
++ * word valid, header valid) aren't exposed here: bench testing found no
++ * useful added precision over RX_DONE/TX_DONE, which is where the
++ * driver's own TX/RX dwell time is stable enough that what's actually
++ * being measured past that point is MCU/radio clock skew, not timing
++ * resolution.
+  * @{
+  */
+-#define SX126X_IRQ_TS_TX_DONE           BIT(0)
+-#define SX126X_IRQ_TS_RX_DONE           BIT(1)
+-#define SX126X_IRQ_TS_PREAMBLE_DETECTED BIT(2)
+-#define SX126X_IRQ_TS_SYNC_WORD_VALID   BIT(3)
+-#define SX126X_IRQ_TS_HEADER_VALID      BIT(4)
+-#define SX126X_IRQ_TS_HEADER_ERR        BIT(5)
+-#define SX126X_IRQ_TS_CRC_ERR           BIT(6)
+-#define SX126X_IRQ_TS_RX_TX_TIMEOUT     BIT(9)
++#define SX126X_IRQ_TS_TX_DONE       BIT(0)
++#define SX126X_IRQ_TS_RX_DONE       BIT(1)
++#define SX126X_IRQ_TS_CRC_ERR       BIT(6)
++#define SX126X_IRQ_TS_RX_TX_TIMEOUT BIT(9)
+ /** @} */
+ 
+ /**
+-- 
+2.54.0 (Apple Git-157)
+

+ 438 - 0
zephyr-patches/0003-drivers-lora-native-sx126x-implement-driver-level-LB.patch

@@ -0,0 +1,438 @@
+From 348754e83885638b36432cec7b1b70199c72f9eb Mon Sep 17 00:00:00 2001
+From: ZiB <zib@zi3.xyz>
+Date: Sat, 7 Nov 2026 15:08:00 -0800
+Subject: [PATCH 3/3] drivers: lora: native: sx126x: implement driver-level LBT
+ send
+
+Adds sx126x_lora_send_lbt(), an SX126x-specific extension that does
+CAD then, only if clear, transmits.
+
+Uses a new SX126X_STATE_CAD_TX state covering the whole CAD->TX op.
+Packet params/FIFO/TX-modulation workaround are staged before SetCAD,
+not after - CAD doesn't touch any of those registers, so this is free.
+On a clear CadDone, the IRQ handler goes straight from "channel is
+clear" to RF-path + SetTx, skipping the sleep that CAD_ONLY's generic
+completion path (sx126x_handle_irq_cad_done()) otherwise always takes.
+
+The two outcomes (busy vs. sent) are delivered over a new dedicated
+lbt_msgq rather than overloading tx_msgq/cad_msgq, since only this
+state's completion needs to carry both. A caller's cad_timeout bounds
+only the CAD phase: lbt_committed tracks whether CAD already went
+clear and SetTx was issued, so a timeout arriving after that point
+waits out the actual transmission (bounded by SetTx's own 10s
+hardware timeout) instead of aborting an in-flight TX.
+
+Retry/backoff policy is deliberately not pulled into the driver.
+
+CAD exit mode stays CAD_ONLY: the SX126x has no CAD-to-TX exit mode in
+hardware (only CAD-to-RX), so this is done by skipping the driver's
+own sleep rather than anything the radio does autonomously.
+
+Signed-off-by: ZiB <zib@zi3.xyz>
+---
+ drivers/lora/native/sx126x/sx126x.c  | 255 +++++++++++++++++++++++++--
+ drivers/lora/native/sx126x/sx126x.h  |  17 ++
+ include/zephyr/drivers/lora/sx126x.h |  24 +++
+ 3 files changed, 277 insertions(+), 19 deletions(-)
+
+diff --git a/drivers/lora/native/sx126x/sx126x.c b/drivers/lora/native/sx126x/sx126x.c
+index 8ba8416ed0c..16037a3714d 100644
+--- a/drivers/lora/native/sx126x/sx126x.c
++++ b/drivers/lora/native/sx126x/sx126x.c
+@@ -817,9 +817,24 @@ static int sx126x_ensure_ready(const struct device *dev)
+ static void sx126x_handle_irq_tx_done(const struct device *dev)
+ {
+ 	struct sx126x_data *data = dev->data;
+-	struct sx126x_tx_result result = { .status = 0 };
+ 
+ 	LOG_DBG("TX done");
++
++	/* A send_lbt() TX-after-clear-CAD never changes data->state away
++	 * from SX126X_STATE_CAD_TX (see sx126x_handle_irq_cad_done_lbt()),
++	 * so that state alone tells a TX_DONE here apart from one started
++	 * by lora_send()/lora_send_async().
++	 */
++	if (atomic_get(&data->state) == SX126X_STATE_CAD_TX) {
++		struct sx126x_lbt_result result = { .status = 0 };
++
++		sx126x_set_sleep(dev);
++		k_msgq_put(&data->lbt_msgq, &result, K_NO_WAIT);
++		return;
++	}
++
++	struct sx126x_tx_result result = { .status = 0 };
++
+ 	sx126x_set_sleep(dev);
+ 
+ 	if (data->tx_async_signal != NULL) {
+@@ -905,14 +920,61 @@ out:
+ 	}
+ }
+ 
++/*
++ * CadDone branch for a send_lbt() op (data->state == SX126X_STATE_CAD_TX).
++ * On activity, this is the whole op's result: sleep and report busy. On a
++ * clear channel, sx126x_lora_send_lbt() already staged packet params/FIFO/
++ * TX-modulation workaround before issuing SetCAD (none of those registers
++ * matter to CAD) - so the only things left between "channel is clear" and
++ * "radio is transmitting" are RF-path + SetTx, issued right here, with no
++ * intervening sleep/wake round-trip. data->state is deliberately left at
++ * SX126X_STATE_CAD_TX (not folded back to SLEEP, not advanced to
++ * SX126X_STATE_TX) so sx126x_handle_irq_tx_done()/sx126x_handle_irq_timeout()
++ * can tell the eventual TX_DONE/timeout apart from one started by
++ * lora_send()/lora_send_async() and route it back to lbt_msgq instead of
++ * tx_msgq.
++ */
++static void sx126x_handle_irq_cad_done_lbt(const struct device *dev, bool activity_detected)
++{
++	struct sx126x_data *data = dev->data;
++	struct sx126x_lbt_result result;
++	int ret;
++
++	if (activity_detected) {
++		LOG_DBG("LBT: channel busy, not transmitting");
++		sx126x_set_sleep(dev);
++		result.status = 1;
++		k_msgq_put(&data->lbt_msgq, &result, K_NO_WAIT);
++		return;
++	}
++
++	sx126x_set_rf_path(dev, true, true);
++
++	ret = sx126x_set_tx(dev, 10000);
++	if (ret < 0) {
++		LOG_ERR("LBT: SetTx failed: %d", ret);
++		sx126x_set_sleep(dev);
++		result.status = ret;
++		k_msgq_put(&data->lbt_msgq, &result, K_NO_WAIT);
++		return;
++	}
++
++	atomic_set(&data->lbt_committed, 1);
++}
++
+ static void sx126x_handle_irq_cad_done(const struct device *dev, uint16_t irq_status)
+ {
+ 	struct sx126x_data *data = dev->data;
+-	struct sx126x_cad_result result = {
+-		.activity_detected = (irq_status & SX126X_IRQ_CAD_ACTIVITY_DETECTED) != 0,
+-	};
++	bool activity_detected = (irq_status & SX126X_IRQ_CAD_ACTIVITY_DETECTED) != 0;
+ 
+-	LOG_DBG("CAD done: activity=%d", result.activity_detected);
++	LOG_DBG("CAD done: activity=%d", activity_detected);
++
++	if (atomic_get(&data->state) == SX126X_STATE_CAD_TX) {
++		sx126x_handle_irq_cad_done_lbt(dev, activity_detected);
++		return;
++	}
++
++	struct sx126x_cad_result result = { .activity_detected = activity_detected };
+ 
+ 	/* CAD_ONLY already left the chip in STDBY_RC; this just folds it
+ 	 * back into the driver's own rest state.
+@@ -947,6 +1009,18 @@ static void sx126x_handle_irq_timeout(const struct device *dev)
+ 		return;
+ 	}
+ 
++	if (atomic_get(&data->state) == SX126X_STATE_CAD_TX) {
++		/* SetTx's own 10s HW timeout, firing on a send_lbt() TX that
++		 * committed after a clear CAD - see
++		 * sx126x_handle_irq_cad_done_lbt().
++		 */
++		struct sx126x_lbt_result result = { .status = -ETIMEDOUT };
++
++		sx126x_set_sleep(dev);
++		k_msgq_put(&data->lbt_msgq, &result, K_NO_WAIT);
++		return;
++	}
++
+ 	sx126x_set_sleep(dev);
+ 
+ 	if (data->tx_async_signal != NULL) {
+@@ -1179,6 +1253,33 @@ out:
+ 	return ret;
+ }
+ 
++/*
++ * Packet params + FIFO + TX-modulation workaround: everything a
++ * transmission needs besides RF-path and SetTx themselves. Shared by
++ * sx126x_lora_send_async() and sx126x_lora_send_lbt(), the latter calling
++ * it *before* SetCAD so that, once CAD comes back clear, only RF-path and
++ * SetTx stand between CadDone and a transmitting radio - see
++ * sx126x_handle_irq_cad_done_lbt().
++ */
++static int sx126x_stage_tx(const struct device *dev, uint8_t *data_buf, uint32_t data_len)
++{
++	struct sx126x_data *data = dev->data;
++	int ret;
++
++	ret = sx126x_set_configured_packet_params(dev, data_len);
++	if (ret < 0) {
++		return ret;
++	}
++
++	ret = sx126x_hal_write_buffer(dev, 0x00, data_buf, data_len);
++	if (ret < 0) {
++		return ret;
++	}
++
++	return sx126x_apply_tx_modulation_workaround(
++		dev, !SX126X_IS_GFSK(data) && data->config.bandwidth == BW_500_KHZ);
++}
++
+ static int sx126x_lora_send_async(const struct device *dev,
+ 				  uint8_t *data_buf, uint32_t data_len,
+ 				  struct k_poll_signal *async)
+@@ -1223,20 +1324,7 @@ static int sx126x_lora_send_async(const struct device *dev,
+ 	data->tx_async_signal = async;
+ 	k_msgq_purge(&data->tx_msgq);
+ 
+-	/* Set packet parameters */
+-	ret = sx126x_set_configured_packet_params(dev, data_len);
+-	if (ret < 0) {
+-		goto out_error;
+-	}
+-
+-	/* Write payload to buffer */
+-	ret = sx126x_hal_write_buffer(dev, 0x00, data_buf, data_len);
+-	if (ret < 0) {
+-		goto out_error;
+-	}
+-
+-	ret = sx126x_apply_tx_modulation_workaround(
+-		dev, !SX126X_IS_GFSK(data) && data->config.bandwidth == BW_500_KHZ);
++	ret = sx126x_stage_tx(dev, data_buf, data_len);
+ 	if (ret < 0) {
+ 		goto out_error;
+ 	}
+@@ -1847,6 +1935,133 @@ static int sx126x_lora_cad_async(const struct device *dev, lora_cad_cb cb, void
+ 	return 0;
+ }
+ 
++/*
++ * Listen-before-talk, done in the driver so the gap between "channel is
++ * clear" and "radio is transmitting" is just RF-path + SetTx, with no
++ * sleep/wake round-trip in between (see sx126x_handle_irq_cad_done_lbt()
++ * and sx126x_stage_tx()). Not part of the generic lora.h API - exposed via
++ * zephyr/drivers/lora/sx126x.h, same as the other SX126x-specific
++ * extensions.
++ *
++ * @p cad_timeout bounds only the CAD phase. Once CAD comes back clear and
++ * the radio commits to transmitting, the wait for TX_DONE is bounded
++ * separately (by SetTx's own 10s hardware timeout) so a short
++ * @p cad_timeout can't abort an in-flight transmission - see
++ * data->lbt_committed below.
++ *
++ * @return 1 if the channel was busy (nothing was sent), 0 if the frame
++ * was sent, or a negative error code.
++ */
++int sx126x_lora_send_lbt(const struct device *dev, uint8_t *data_buf, uint32_t data_len,
++			 k_timeout_t cad_timeout)
++{
++	struct sx126x_data *data = dev->data;
++	struct sx126x_cad_defaults defaults;
++	enum lora_cad_symbol_num symbol_num;
++	uint8_t det_peak, det_min, symbol_num_reg;
++	struct sx126x_lbt_result result;
++	int ret;
++
++	if (!data->config_valid) {
++		LOG_ERR("Not configured");
++		return -EINVAL;
++	}
++
++	if (SX126X_IS_GFSK(data)) {
++		LOG_ERR("CAD is a LoRa-only operation");
++		return -ENOTSUP;
++	}
++
++	if (data_len > SX126X_MAX_PAYLOAD_LEN) {
++		LOG_ERR("Payload too long: %u", data_len);
++		return -EINVAL;
++	}
++
++	symbol_num = data->config.cad.symbol_num;
++	det_peak = data->config.cad.detection_peak;
++	det_min = data->config.cad.detection_minimum;
++
++	if (symbol_num == 0 || det_peak == 0 || det_min == 0) {
++		ret = sx126x_cad_lookup_defaults(data->config.bandwidth, data->config.datarate,
++						 &defaults);
++		if (ret < 0) {
++			LOG_ERR("No CAD defaults for this bandwidth/SF; "
++				"set cad.symbol_num/detection_peak/detection_minimum explicitly");
++			return ret;
++		}
++		symbol_num = symbol_num ? symbol_num : defaults.symbol_num;
++		det_peak = det_peak ? det_peak : defaults.det_peak;
++		det_min = det_min ? det_min : defaults.det_min;
++	}
++
++	ret = sx126x_cad_symbol_num_to_reg(symbol_num, &symbol_num_reg);
++	if (ret < 0) {
++		LOG_ERR("Invalid CAD symbol count: %d", symbol_num);
++		return ret;
++	}
++
++	if (!atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_CAD_TX)) {
++		LOG_ERR("Busy");
++		return -EBUSY;
++	}
++
++	k_mutex_lock(&data->lock, K_FOREVER);
++	k_msgq_purge(&data->lbt_msgq);
++	atomic_set(&data->lbt_committed, 0);
++
++	ret = sx126x_ensure_ready(dev);
++	if (ret < 0) {
++		goto out_error;
++	}
++
++	/* Stage the payload now, while the chip is awake in STDBY_RC and
++	 * before SetCAD - CAD doesn't touch packet params/FIFO/TX-mod, so
++	 * none of this needs to happen again after CadDone.
++	 */
++	ret = sx126x_stage_tx(dev, data_buf, data_len);
++	if (ret < 0) {
++		goto out_error;
++	}
++
++	ret = sx126x_set_cad_params(dev, symbol_num_reg, det_peak, det_min,
++				    SX126X_CAD_EXIT_MODE_CAD_ONLY, 0);
++	if (ret < 0) {
++		goto out_error;
++	}
++
++	ret = sx126x_set_cad(dev);
++	if (ret < 0) {
++		goto out_error;
++	}
++
++	k_mutex_unlock(&data->lock);
++
++	ret = k_msgq_get(&data->lbt_msgq, &result, cad_timeout);
++	if (ret < 0 && atomic_get(&data->lbt_committed)) {
++		/* CAD already came back clear and TX is underway; the short
++		 * CAD-phase wait above isn't the right bound for that - wait
++		 * out the transmission itself instead of aborting it.
++		 */
++		ret = k_msgq_get(&data->lbt_msgq, &result, K_SECONDS(15));
++	}
++
++	if (ret < 0) {
++		LOG_DBG("LBT: timeout");
++		k_mutex_lock(&data->lock, K_FOREVER);
++		sx126x_set_standby(dev, SX126X_STANDBY_RC);
++		sx126x_set_sleep(dev);
++		k_mutex_unlock(&data->lock);
++		return -ETIMEDOUT;
++	}
++
++	return result.status;
++
++out_error:
++	sx126x_set_sleep(dev);
++	k_mutex_unlock(&data->lock);
++	return ret;
++}
++
+ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency,
+ 			       int8_t tx_power, uint16_t duration)
+ {
+@@ -1951,6 +2166,8 @@ static int sx126x_init(const struct device *dev)
+ 		    sizeof(struct sx126x_rx_result), 1);
+ 	k_msgq_init(&data->cad_msgq, (char *)&data->cad_result,
+ 		    sizeof(struct sx126x_cad_result), 1);
++	k_msgq_init(&data->lbt_msgq, (char *)&data->lbt_result,
++		    sizeof(struct sx126x_lbt_result), 1);
+ 	k_work_init(&data->irq_work, sx126x_irq_work_handler);
+ 	data->dev = dev;
+ 	atomic_set(&data->state, SX126X_STATE_IDLE);
+diff --git a/drivers/lora/native/sx126x/sx126x.h b/drivers/lora/native/sx126x/sx126x.h
+index 5f22f620f35..d831d62aaa8 100644
+--- a/drivers/lora/native/sx126x/sx126x.h
++++ b/drivers/lora/native/sx126x/sx126x.h
+@@ -20,6 +20,7 @@ enum sx126x_state {
+ 	SX126X_STATE_RX,
+ 	SX126X_STATE_RX_DUTY_CYCLE,
+ 	SX126X_STATE_CAD,
++	SX126X_STATE_CAD_TX,
+ };
+ 
+ struct sx126x_tx_result {
+@@ -37,6 +38,11 @@ struct sx126x_cad_result {
+ 	bool activity_detected;
+ };
+ 
++struct sx126x_lbt_result {
++	/* 1 = channel busy, nothing transmitted; 0 = sent; <0 = error */
++	int status;
++};
++
+ struct sx126x_data {
+ 	struct sx126x_hal_data hal;
+ 
+@@ -76,6 +82,17 @@ struct sx126x_data {
+ 	lora_cad_cb cad_cb;
+ 	void *cad_cb_user_data;
+ 
++	/* sx126x_lora_send_lbt() completion via message queue */
++	struct k_msgq lbt_msgq;
++	struct sx126x_lbt_result lbt_result;
++	/*
++	 * Set once a send_lbt() op's CAD comes back clear and SetTx has
++	 * been issued, so a caller whose CAD-phase wait times out knows
++	 * the radio is already transmitting rather than aborting it - see
++	 * sx126x_lora_send_lbt().
++	 */
++	atomic_t lbt_committed;
++
+ 	/* RX duty cycle parameters (in SX126x timeout ticks) */
+ 	struct {
+ 		uint32_t rx_period;
+diff --git a/include/zephyr/drivers/lora/sx126x.h b/include/zephyr/drivers/lora/sx126x.h
+index 4052f86aa41..0a2dc9c13c1 100644
+--- a/include/zephyr/drivers/lora/sx126x.h
++++ b/include/zephyr/drivers/lora/sx126x.h
+@@ -9,6 +9,7 @@
+ #define ZEPHYR_INCLUDE_DRIVERS_LORA_SX126X_H_
+ 
+ #include <zephyr/device.h>
++#include <zephyr/kernel.h>
+ #include <zephyr/sys/util.h>
+ #include <stdint.h>
+ #include <stdbool.h>
+@@ -51,6 +52,29 @@ extern "C" {
+  */
+ bool sx126x_irq_timestamp_get(const struct device *dev, uint16_t irq_bit, uint64_t *ns_out);
+ 
++/**
++ * @brief Listen-before-talk send: CAD, then transmit only if clear.
++ *
++ * This issues a single CAD attempt; retry/backoff policy belongs at the
++ * caller (e.g. a TX queue).
++ *
++ * @param dev         SX126x LoRa device, already configured via
++ *                    @ref lora_config
++ * @param data        Payload to send if the channel is clear
++ * @param len         Payload length
++ * @param cad_timeout Bound on the CAD phase only. Once CAD comes back
++ *                    clear and the radio commits to transmitting, the
++ *                    wait for TX completion is bounded separately (by the
++ *                    radio's own hardware TX timeout) so a short
++ *                    @p cad_timeout here can never abort an in-flight
++ *                    transmission.
++ * @return 1 if the channel was busy and nothing was sent, 0 if the frame
++ *         was sent, or a negative error code (including -ETIMEDOUT if
++ *         CAD itself never completed within @p cad_timeout)
++ */
++int sx126x_lora_send_lbt(const struct device *dev, uint8_t *data, uint32_t len,
++			 k_timeout_t cad_timeout);
++
+ #ifdef __cplusplus
+ }
+ #endif
+-- 
+2.54.0 (Apple Git-157)
+