firmware_techo/main.rs
1// ULCP device firmware shared by the LilyGO T-Echo and Seeed
2// SenseCAP T-1000E targets. Cargo features select the board-specific glue.
3//
4// Exposes the board's LoRa radio as a host-controlled PHY speaking the minimal
5// ULCP protocol plus advertised full-profile extensions
6// over USB-CDC/HDLC-Lite and encrypted+bonded BLE GATT/SAR. The host owns its
7// own MAC and drives this device through `umsh::ulcp::UlcpDevice`;
8// alongside that session, the **device node** (device_node.rs) runs a full
9// on-board MAC/node stack for the device identity, sharing the radio through
10// the mux.
11//
12// Protocol behavior lives in `umsh-ulcp-device::Session` (host-tested,
13// no I/O); this binary is only glue:
14//
15// Task layout (steady state):
16// - main(): initializes MPSL/SDC and joins BLE, USB, heartbeat
17// - radio_task: owns lora_phy::LoRa via umsh_radio_loraphy::device_runner;
18// modulation/frequency/power are pushed at runtime
19// through DEVICE_CTL as the host sets properties
20// - radio_mux_task: multiplexes the physical radio across its clients
21// (per-client TX completion routing, RX fan-out);
22// the session is client A, the device node client B
23// - node_pump_task / the device node: MAC pump + beacon requests for
24// node_beacon_task the device identity (spawned only when a persisted
25// identity exists; dormant otherwise)
26// - usb_in_task: owns CdcAcmRescue + HDLC decoder; forwards frames and
27// attach/detach edges into INPUT_CH (keeps
28// read_packet out of any select, so cancel safety
29// never depends on the USB driver)
30// - device_task: hosts the shared ULCP driver
31// (umsh_ulcp_runtime::driver): the Session
32// select loop over INPUT_CH, radio RX, and TX
33// completions, with board couplings via BoardDeviceEnv
34// - output_task: owns the USB Sender + HDLC encoder, drains
35// OUT_CH.wired
36// - ble_app: advertising + encrypted/bond-gated GATT/SAR edges,
37// pairing policy, generation-tagged OUT_CH.ble, and
38// MPSL-coordinated PIN/bond persistence
39// - button_task: resolves the side button into display-menu gestures
40// - display_task: owns the e-paper BLE menu and its attention policy
41// - touch_task: publishes the touch button's backlight demand
42// - backlight_task: arbitrates backlight demand (locate alert wins)
43// - shutdown_task: tri-states peripheral pins, drops the rail,
44// enters System OFF
45//
46// CMD_RST is a protocol-level reset: all protocol state returns to
47// post-reset values and the radio is re-applied (disabled), but the MCU
48// and the USB link stay up. Host attach resets only session state
49// (full-protocol semantics): the device domain — PHY configuration and
50// enable state, device name, duty accounting — is untouched, and
51// nothing is emitted; the reset notice is only sent for CMD_RST, so the
52// host never sees an unsolicited reset it didn't ask for mid-handshake.
53//
54// Safety primitives inherited from the BSP (see umsh-bsp-nrf52840):
55// * Panic capture into reserved RAM (reported as STATUS_RESET_CRASH).
56// * 1200-baud touchless reset and Ctrl-C × 3 + "dfu" escape to
57// bootloader (baked into CdcAcmRescue).
58// * Watchdog.
59
60#![cfg_attr(target_os = "none", no_std)]
61#![cfg_attr(target_os = "none", no_main)]
62// The no-ble diagnostic image compiles the full BLE support source
63// (pairing policy, bond store, GATT plumbing) with the call sites
64// cfg'd out. Silence the resulting dead-code noise for that image
65// only, so production builds keep full warning strength.
66#![cfg_attr(feature = "no-ble", allow(dead_code, unused_imports))]
67
68#[cfg(not(target_os = "none"))]
69fn main() {
70 // Host placeholder. This binary only runs on the embedded target.
71}
72
73// The device node's advertisement payloads use umsh-node's alloc-backed
74// types (they draw from the same 8 KiB heap the node stack already
75// uses).
76extern crate alloc;
77
78// Global heap allocator. The device node (umsh-sync's AsyncRefCell plus
79// umsh-node's Rc-based plumbing) allocates a small bounded amount at
80// bring-up; the ULCP session remains allocation-free. Initialized
81// with an 8 KiB region at the top of main() — the same budget the CLI
82// firmware's full stack runs in on identical hardware.
83#[cfg(target_os = "none")]
84#[global_allocator]
85static ALLOCATOR: embedded_alloc::Heap = embedded_alloc::Heap::empty();
86
87// Board-agnostic leaf modules now live in `umsh-ulcp-runtime` (Phase 5
88// extraction, increment A). Re-import them under their original names so the
89// `super::<module>` paths inside `mod firmware` resolve unchanged. Gated to the
90// firmware target because the host build compiles `mod firmware` out entirely.
91#[cfg(target_os = "none")]
92use umsh_ulcp_runtime::{ble_security, radio_mux, transport_policy};
93#[cfg_attr(not(target_os = "none"), allow(dead_code))]
94mod ble_store;
95#[cfg(target_os = "none")]
96mod device_node;
97mod proto_store;
98
99// The #[panic_handler] must live in the binary crate.
100#[cfg(target_os = "none")]
101mod panic;
102
103// lora-phy 3.x unconditionally depends on defmt. Provide a zero-overhead
104// no-op global logger so this binary links without any debug transport.
105#[cfg(target_os = "none")]
106mod defmt_logger {
107 #[defmt::global_logger]
108 struct Logger;
109 unsafe impl defmt::Logger for Logger {
110 fn acquire() {}
111 unsafe fn flush() {}
112 unsafe fn release() {}
113 unsafe fn write(_: &[u8]) {}
114 }
115 defmt::timestamp!("{=u32}", 0u32);
116}
117
118#[cfg(target_os = "none")]
119mod firmware {
120 use super::ble_security::{PairingFailureClass, PairingRuntime, pairing_enabled};
121 use super::ble_store::{self, Snapshot, StoredBond};
122 use super::proto_store;
123 use super::transport_policy::{Transport, generation_checked};
124 #[cfg(feature = "ble-debug")]
125 use core::fmt::Write as _;
126 use core::sync::atomic::{AtomicBool, AtomicU8, AtomicU16, AtomicU32, Ordering};
127 use embassy_executor::Spawner;
128 use embassy_futures::join::join;
129 use embassy_futures::select::{Either, Either3, select, select3};
130 #[cfg(any(feature = "has-display", feature = "button-nav", feature = "t1000e"))]
131 use embassy_futures::select::{Either4, select4};
132 use embassy_nrf::bind_interrupts;
133 #[cfg(feature = "cap-gnss")]
134 use embassy_nrf::buffered_uarte::BufferedUarte;
135 use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
136 use embassy_nrf::mode::Async;
137 use embassy_nrf::pac;
138 use embassy_nrf::peripherals::{self, RNG};
139 #[cfg(feature = "t1000e")]
140 use embassy_nrf::pwm::DutyCycle;
141 #[cfg(any(feature = "t1000e", feature = "cap-buzzer"))]
142 use embassy_nrf::pwm::{Prescaler, SimpleConfig, SimplePwm};
143 use embassy_nrf::rng;
144 // The T-1000E is the one board whose converter is built in the BSP,
145 // per measurement, because its battery and light channels want
146 // different resolutions and oversampling. Every other board hands the
147 // BSP a ready-made single-channel `Saadc` from here.
148 #[cfg(feature = "t1000e")]
149 use embassy_nrf::Peri;
150 #[cfg(all(feature = "cap-battery-saadc", not(feature = "t1000e")))]
151 use embassy_nrf::saadc::{ChannelConfig, Config as SaadcConfig, Saadc};
152 use embassy_nrf::spim::{Config as SpimConfig, Frequency, Spim};
153 #[cfg(feature = "cap-gnss")]
154 use embassy_nrf::uarte::{Baudrate as UarteBaudrate, Config as UarteConfig};
155 use embassy_nrf::usb::Driver;
156 use embassy_nrf::usb::vbus_detect::SoftwareVbusDetect;
157 use embassy_nrf::wdt::{Config as WdtConfig, Watchdog, WatchdogHandle};
158 use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
159 use embassy_sync::channel::Channel;
160 use embassy_sync::mutex::Mutex;
161 use embassy_sync::signal::Signal;
162 use embassy_time::{Delay, Duration, Instant, Timer};
163 use embassy_usb::class::cdc_acm::{CdcAcmClass, State};
164 use embassy_usb::{Builder, Config};
165 use embedded_hal_bus::spi::ExclusiveDevice;
166 use lora_phy::LoRa;
167 #[cfg(feature = "t1000e")]
168 use lora_phy::iv::GenericLr1110InterfaceVariant;
169 #[cfg(not(feature = "t1000e"))]
170 use lora_phy::iv::GenericSx126xInterfaceVariant;
171 #[cfg(feature = "t1000e")]
172 use lora_phy::lr1110::{
173 Config as LoraConfig, Lr1110, TcxoCtrlVoltage, radio_kind_params::PaSelection,
174 variant::Lr1110 as Lr1110Chip,
175 };
176 #[cfg(not(feature = "t1000e"))]
177 use lora_phy::sx126x::{Config as LoraConfig, Sx126x, Sx1262, TcxoCtrlVoltage};
178 use nrf_sdc::mpsl::{self, MultiprotocolServiceLayer};
179 use nrf_sdc::{self as sdc};
180 use static_cell::StaticCell;
181 use trouble_host::gap;
182 use trouble_host::prelude::*;
183 use umsh_bsp_nrf52840::cdc_rescue::CdcAcmRescue;
184 use umsh_bsp_nrf52840::panic_persist::PanicSlot;
185 #[cfg(any(
186 feature = "system-off-techo",
187 feature = "system-off-wio",
188 feature = "t1000e"
189 ))]
190 use umsh_bsp_nrf52840::system_off::Port;
191 #[cfg(feature = "system-off-techo")]
192 use umsh_bsp_nrf52840::system_off::drive_pin_high;
193 #[cfg(any(
194 feature = "t1000e",
195 feature = "system-off-wio",
196 feature = "system-off-techo"
197 ))]
198 use umsh_bsp_nrf52840::system_off::drive_pin_low;
199 #[cfg(any(feature = "system-off-techo", feature = "system-off-wio"))]
200 use umsh_bsp_nrf52840::system_off::{WakePin, WakeSense, power_off, tristate_pin};
201 #[cfg(any(feature = "system-off-techo", feature = "system-off-wio"))]
202 use umsh_bsp_nrf52840::system_off::{WakePull, connect_input, read_pin};
203 #[cfg(feature = "t1000e")]
204 use umsh_bsp_t1000e::RF_SWITCH;
205 #[cfg(feature = "display-epd")]
206 use umsh_bsp_techo::display;
207 #[cfg(feature = "display-oled")]
208 use umsh_bsp_wio_tracker_l1::display;
209 // Board-selected battery BSP module, used only by the shared
210 // `cap-battery-saadc` snapshot/load-hint code below.
211 #[cfg(all(feature = "cap-battery-saadc", feature = "board-sensecap-solar"))]
212 use umsh_bsp_sensecap_solar::power as board_power;
213 #[cfg(all(feature = "cap-battery-saadc", feature = "t1000e"))]
214 use umsh_bsp_t1000e::power as board_power;
215 #[cfg(all(feature = "cap-battery-saadc", feature = "board-techo"))]
216 use umsh_bsp_techo::power as board_power;
217 #[cfg(all(feature = "cap-battery-saadc", feature = "board-wio-tracker-l1"))]
218 use umsh_bsp_wio_tracker_l1::power as board_power;
219 #[cfg(all(feature = "cap-battery-saadc", feature = "board-xiao-nrf52"))]
220 use umsh_bsp_xiao_nrf52::power as board_power;
221 // Board-selected GNSS power control. One board feature is active per
222 // image, so this alias resolves to exactly one type and the pump's
223 // task shim stays concrete — which is what `#[embassy_executor::task]`
224 // requires, since a task function cannot be generic.
225 #[cfg(all(feature = "cap-gnss", feature = "board-techo"))]
226 type BoardGnss = umsh_bsp_techo::gnss::Gnss<'static>;
227 #[cfg(all(feature = "cap-gnss", feature = "t1000e"))]
228 type BoardGnss = umsh_bsp_t1000e::gnss::Gnss<'static>;
229 #[cfg(all(feature = "cap-gnss", feature = "board-wio-tracker-l1"))]
230 type BoardGnss = umsh_bsp_wio_tracker_l1::gnss::Gnss<'static>;
231 #[cfg(all(feature = "cap-gnss", feature = "board-sensecap-solar"))]
232 type BoardGnss = umsh_bsp_sensecap_solar::gnss::Gnss<'static>;
233 /// The byte stream the pump reads.
234 #[cfg(feature = "cap-gnss")]
235 type GnssUart = BufferedUarte<'static>;
236 use umsh_crypto::CryptoEngine;
237 use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
238 use umsh_ulcp::{Status, gatt, hdlc};
239 #[cfg(feature = "cap-gnss")]
240 use umsh_ulcp_device::GnssConfig;
241 use umsh_ulcp_device::{
242 AlertConfig, BatteryFields, MAX_DEVICE_NAME_LEN, RadioSettings, SessionConfig, TimeConfig,
243 };
244
245 /// The ULCP session instantiated with this firmware's crypto
246 /// providers (software AES/SHA; Ed25519 comes in only through the
247 /// device-identity provisioning path).
248 // The physical radio remains single-flight, but the protocol session can
249 // retain several host frames. This target-specific const generic avoids a
250 // LoRa completion round trip between fragments without imposing the RAM
251 // cost on smaller/default Session users.
252 const ULCP_TX_QUEUE_CAPACITY: usize = 8;
253 type Session = umsh_ulcp_device::Session<SoftwareAes, SoftwareSha256, ULCP_TX_QUEUE_CAPACITY>;
254
255 /// Deterministic CSPRNG for device-identity generation, seeded from
256 /// the hardware TRNG at boot: the RNG peripheral itself is owned by
257 /// the SoftDevice Controller for the lifetime of the BLE stack.
258 type IdentityRng = rand_chacha::ChaCha20Rng;
259 use umsh_radio_loraphy::{DeviceControl, MAX_PAYLOAD};
260 use umsh_ulcp_runtime::driver::{
261 self, DeviceEnv, DeviceRuntime, InEvent, InputChannel, OutFrame, TransportChannels,
262 };
263 #[cfg(feature = "has-display")]
264 use umsh_ux_display_tracker::attention::{
265 Attention, AttentionConfig, DisplayKind, HoldReason, Transition,
266 };
267 #[cfg(feature = "button-nav")]
268 use umsh_ux_display_tracker::gate::{Disposition, Gate, GateReason};
269 use umsh_ux_display_tracker::menu::UiNotice;
270 #[cfg(feature = "has-display")]
271 use umsh_ux_display_tracker::menu::{MenuItems, UiEffect, UiInput, UiModel};
272 #[cfg(feature = "has-display")]
273 use umsh_ux_display_tracker::screen;
274 #[cfg(any(feature = "button-nav", feature = "t1000e"))]
275 use umsh_ux_tracker::button::{ButtonEdge, ButtonEvent, ButtonFsm};
276 // The display trackers take their timings from the shared class
277 // policy; only the headless T-1000E still names its own.
278 #[cfg(feature = "t1000e")]
279 use umsh_ux_tracker::button::ButtonTimings;
280 #[cfg(feature = "t1000e")]
281 use umsh_ux_tracker::buzzer::melodies as buzzer_melodies;
282 #[cfg(feature = "t1000e")]
283 use umsh_ux_tracker::led::T1000eLedEngine;
284 #[cfg(not(feature = "t1000e"))]
285 use umsh_ux_tracker::led::{LedEngine, LedTimings};
286 // The Solar P1's attention LED plays the same sequences from the
287 // generic engine.
288 #[cfg(any(feature = "t1000e", feature = "power-button"))]
289 use umsh_ux_tracker::led::LedSequence;
290
291 bind_interrupts!(struct Irqs {
292 USBD => embassy_nrf::usb::InterruptHandler<peripherals::USBD>;
293 RNG => rng::InterruptHandler<RNG>;
294 EGU0_SWI0 => nrf_sdc::mpsl::LowPrioInterruptHandler;
295 CLOCK_POWER => nrf_sdc::mpsl::ClockInterruptHandler;
296 RADIO => nrf_sdc::mpsl::HighPrioInterruptHandler;
297 TIMER0 => nrf_sdc::mpsl::HighPrioInterruptHandler;
298 RTC0 => nrf_sdc::mpsl::HighPrioInterruptHandler;
299 // TWIM0/SPIM0 is the one peripheral this family uses two ways:
300 // SPIM0 → LR1110 on the T-1000E, TWIM0 → SH1106 OLED on the Wio
301 // Tracker L1. Both handlers cannot be bound at once — they claim
302 // the same peripheral — so the board picks.
303 TWISPI0 =>
304 #[cfg(not(feature = "display-oled"))]
305 embassy_nrf::spim::InterruptHandler<peripherals::TWISPI0>,
306 #[cfg(feature = "display-oled")]
307 embassy_nrf::twim::InterruptHandler<peripherals::TWISPI0>;
308 // SPIM1 → SX1262 LoRa SPI bus. embassy-nrf names this peripheral
309 // TWISPI1 (it's the shared TWIM1/SPIM1 block on nRF52840).
310 TWISPI1 => embassy_nrf::spim::InterruptHandler<peripherals::TWISPI1>;
311 // SPIM2 → SSD1681 e-paper SPI bus. embassy-nrf names this interrupt SPI2.
312 SPI2 => embassy_nrf::spim::InterruptHandler<peripherals::SPI2>;
313 SAADC => embassy_nrf::saadc::InterruptHandler;
314 // UARTE0 → the GNSS receiver, on every board that has one. Bound
315 // unconditionally rather than per-board: an unused handler for a
316 // peripheral nothing instantiates costs a vector-table entry and
317 // saves a `cfg` fork in the one block that must stay readable.
318 UARTE0 => embassy_nrf::buffered_uarte::InterruptHandler<peripherals::UARTE0>;
319 });
320
321 // ─── Configuration ───────────────────────────────────────────────────────
322
323 /// SX1262 PA limits on this module.
324 const MIN_TX_POWER_DBM: i8 = -9;
325 const MAX_TX_POWER_DBM: i8 = 22;
326
327 const BLE_CONNECTIONS_MAX: usize = 1;
328 const BLE_L2CAP_CHANNELS_MAX: usize = 2;
329 const BLE_L2CAP_TXQ: u8 = 3;
330 const BLE_L2CAP_RXQ: u8 = 3;
331 /// Nordic's SDC buffer configuration accepts 27..=251 octets.
332 const SDC_PACKET_SIZE: u16 = 251;
333 /// Largest value the ULCP characteristics accept.
334 ///
335 /// A client may write up to ATT_MTU-3 octets in one request, and the
336 /// packet pool is configured for a 255-octet MTU, so anything smaller
337 /// than 252 here is a size the peer is entitled to send and this device
338 /// would refuse with an invalid-length error.
339 const BLE_VALUE_MAX: usize = 252;
340 #[cfg(feature = "board-techo")]
341 const DEFAULT_DEVICE_NAME: &str = "UMSH T-Echo";
342 #[cfg(feature = "t1000e")]
343 const DEFAULT_DEVICE_NAME: &str = "UMSH T-1000E";
344 // Board default + " XXXX" suffix must stay within trouble's 22-byte
345 // GAP device-name limit; a longer name fails GATT-server construction
346 // (the cause of the Solar P1 first-bringup boot loop).
347 #[cfg(feature = "board-sensecap-solar")]
348 const DEFAULT_DEVICE_NAME: &str = "UMSH Solar";
349 #[cfg(feature = "board-wio-tracker-l1")]
350 const DEFAULT_DEVICE_NAME: &str = "UMSH Wio L1";
351 #[cfg(feature = "board-xiao-nrf52")]
352 const DEFAULT_DEVICE_NAME: &str = "UMSH XIAO";
353
354 /// The board default name plus a stable per-die suffix — the low 16
355 /// bits of FICR DEVICEADDR, the same die-unique value the BLE
356 /// identity address is built from — so factory-fresh radios are
357 /// tellable apart in scan lists and on multi-board benches.
358 fn default_device_name() -> &'static str {
359 use core::fmt::Write as _;
360 static NAME: embassy_sync::once_lock::OnceLock<heapless09::String<24>> =
361 embassy_sync::once_lock::OnceLock::new();
362 NAME.get_or_init(|| {
363 let suffix = embassy_nrf::pac::FICR.deviceaddr(0).read() & 0xFFFF;
364 let mut name = heapless09::String::new();
365 let _ = write!(name, "{DEFAULT_DEVICE_NAME} {suffix:04X}");
366 name
367 })
368 .as_str()
369 }
370
371 #[gatt_server]
372 struct UlcpServer {
373 ulcp: UlcpService,
374 }
375
376 #[gatt_service(uuid = "21eb6b15-0001-4ccf-92e4-a079171bec97")]
377 struct UlcpService {
378 #[characteristic(
379 uuid = "21eb6b15-0002-4ccf-92e4-a079171bec97",
380 write,
381 write_without_response,
382 permissions(write = encrypted)
383 )]
384 frame_in: heapless09::Vec<u8, BLE_VALUE_MAX>,
385 #[characteristic(
386 uuid = "21eb6b15-0003-4ccf-92e4-a079171bec97",
387 notify,
388 permissions(cccd = encrypted)
389 )]
390 frame_out: heapless09::Vec<u8, BLE_VALUE_MAX>,
391 }
392
393 /// `PROP_DEV_VERSION`: the board's firmware name and `git describe
394 /// --always` (from the build script), nothing else. Boot
395 /// diagnostics stay on the debug console.
396 #[cfg(feature = "board-techo")]
397 const DEV_VERSION: &str = concat!("umsh-techo ", env!("GIT_DESCRIBE"));
398 #[cfg(feature = "t1000e")]
399 const DEV_VERSION: &str = concat!("umsh-t1000e ", env!("GIT_DESCRIBE"));
400 #[cfg(feature = "board-sensecap-solar")]
401 const DEV_VERSION: &str = concat!("umsh-sensecap-solar ", env!("GIT_DESCRIBE"));
402 #[cfg(feature = "board-wio-tracker-l1")]
403 const DEV_VERSION: &str = concat!("umsh-wio-tracker-l1 ", env!("GIT_DESCRIBE"));
404 #[cfg(feature = "board-xiao-nrf52")]
405 const DEV_VERSION: &str = concat!("umsh-xiao-nrf52 ", env!("GIT_DESCRIBE"));
406
407 fn session_config() -> SessionConfig {
408 SessionConfig {
409 dev_version: DEV_VERSION,
410 default_device_name: default_device_name(),
411 mtu: MAX_PAYLOAD as u16,
412 // Fixed at build time: LoRa::new(.., false, ..) below sets the
413 // private-network word 0x12 → SX126x registers 0x1424.
414 sync_word: 0x1424,
415 min_tx_power_dbm: MIN_TX_POWER_DBM,
416 max_tx_power_dbm: MAX_TX_POWER_DBM,
417 // SX1262 tunable range.
418 freq_khz_min: 150_000,
419 freq_khz_max: 960_000,
420 // Post-reset defaults (PHY disabled until the host enables it);
421 // RF values match the MeshCore-US bringup profile.
422 defaults: RadioSettings {
423 enabled: false,
424 freq_khz: 910_525,
425 bw_hz: 62_500,
426 sf: 7,
427 cr_denom: 5,
428 tx_power_dbm: 14,
429 },
430 default_duty_limit: umsh_ulcp::ids::DUTY_LIMIT_DISABLED,
431 duty: &DUTY_LEDGER,
432 // Every board here is battery powered, and every one now has
433 // a SAADC monitor reporting voltage, charge state, and the
434 // rest-gated OCV level estimate.
435 #[cfg(feature = "cap-battery-saadc")]
436 battery: Some(BatteryFields {
437 voltage: true,
438 level: true,
439 charge_state: true,
440 }),
441 #[cfg(not(feature = "cap-battery-saadc"))]
442 battery: Some(BatteryFields::NONE),
443 // Every board here can make itself conspicuous: the T-1000E
444 // with its buzzer, the T-Echo and Solar P1 with their
445 // indicator LEDs. `CAP_ALERT` says only that *something*
446 // happens, so the difference stays a board matter.
447 alert: Some(AlertConfig::DEFAULT),
448 // Every board here keeps a wall clock. What it does *not*
449 // claim is that the clock is set: a board with no receiver
450 // and no battery-backed RTC simply reports that it does not
451 // know what time it is until a host tells it.
452 time: Some(TimeConfig),
453 // The Solar P1 is the one board here that runs its receiver
454 // by default. It is a fixed outdoor node with a panel rather
455 // than a pocket tracker on a cell: the load it is worried
456 // about is the one it can see coming, and a node that has to
457 // be told to find itself after every reset is the worse
458 // failure. Everywhere else the receiver waits to be asked.
459 #[cfg(all(feature = "cap-gnss", feature = "board-sensecap-solar"))]
460 gnss: Some(GnssConfig::ALWAYS_ON),
461 #[cfg(all(feature = "cap-gnss", not(feature = "board-sensecap-solar")))]
462 gnss: Some(GnssConfig::DEFAULT),
463 #[cfg(not(feature = "cap-gnss"))]
464 gnss: None,
465 // The T-1000E is the one board here with an ambient light
466 // sensor fitted.
467 illuminance: cfg!(feature = "cap-illuminance"),
468 }
469 }
470
471 /// The one duty ledger shared by every radio client (device-node
472 /// plan increment 4): the session prices and records its own
473 /// transmissions here, and the device node's radio path admits
474 /// each transmit against the same combined budget (`duty_gate`),
475 /// so `PROP_PHY_DUTY_LIMIT` bounds session + node airtime together
476 /// and `PROP_PHY_DUTY_NOW` reports the combined figure.
477 pub(crate) static DUTY_LEDGER: umsh_ulcp_device::DutyLedger =
478 umsh_ulcp_device::DutyLedger::new();
479
480 // ─── Concrete types ──────────────────────────────────────────────────────
481
482 type RadioSpiBus = ExclusiveDevice<Spim<'static>, Output<'static>, Delay>;
483 #[cfg(not(feature = "t1000e"))]
484 type RadioIv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
485 #[cfg(feature = "t1000e")]
486 type RadioIv = GenericLr1110InterfaceVariant<Output<'static>, Input<'static>>;
487 #[cfg(not(feature = "t1000e"))]
488 type RadioKind = Sx126x<RadioSpiBus, RadioIv, Sx1262>;
489 #[cfg(feature = "t1000e")]
490 type RadioKind = Lr1110<RadioSpiBus, RadioIv, Lr1110Chip>;
491 type LoraRadio = LoRa<RadioKind, Delay>;
492
493 type DeviceUsbDriver = Driver<'static, &'static SoftwareVbusDetect>;
494 type DeviceSender = embassy_usb::class::cdc_acm::Sender<'static, DeviceUsbDriver>;
495 type DeviceRescue = CdcAcmRescue<'static, DeviceUsbDriver>;
496 type BleStoreMutex = Mutex<ThreadModeRawMutex, BleStore>;
497 /// The one MPSL-coordinated flash driver, shared between the BLE
498 /// bond/PIN journal and the protocol snapshot journal.
499 pub type SharedFlash = Mutex<ThreadModeRawMutex, JournalFlash>;
500
501 /// Local wrapper carrying the `umsh-journal-store` trait impls for
502 /// the MPSL-coordinated flash (both trait and driver are foreign
503 /// since the journal extraction, so the impls need a local type).
504 /// Derefs to the driver for the blocking read paths.
505 pub struct JournalFlash(nrf_mpsl::Flash<'static>);
506
507 impl core::ops::Deref for JournalFlash {
508 type Target = nrf_mpsl::Flash<'static>;
509
510 fn deref(&self) -> &Self::Target {
511 &self.0
512 }
513 }
514
515 impl core::ops::DerefMut for JournalFlash {
516 fn deref_mut(&mut self) -> &mut Self::Target {
517 &mut self.0
518 }
519 }
520
521 struct BleStore {
522 flash: &'static SharedFlash,
523 snapshot: Snapshot,
524 slot: Option<u32>,
525 }
526
527 impl ble_store::RecordWriter for JournalFlash {
528 type Error = ();
529
530 async fn write_record(&mut self, address: u32, bytes: &[u8]) -> Result<(), Self::Error> {
531 #[cfg(feature = "ble-store-fault-inject")]
532 if BLE_STORE_FAULT_ARMED.load(Ordering::Acquire) {
533 debug_log(format_args!(
534 "store fault-inject write address=0x{address:06x} len={}",
535 bytes.len(),
536 ));
537 return Err(());
538 }
539 self.write(address, bytes).await.map_err(|_| ())
540 }
541 }
542
543 impl ble_store::PageEraser for JournalFlash {
544 type Error = ();
545
546 async fn erase_page(&mut self, start: u32, end: u32) -> Result<(), Self::Error> {
547 #[cfg(feature = "ble-store-fault-inject")]
548 if BLE_STORE_FAULT_ARMED.load(Ordering::Acquire) {
549 debug_log(format_args!(
550 "store fault-inject erase start=0x{start:06x} end=0x{end:06x}",
551 ));
552 return Err(());
553 }
554 self.erase(start, end).await.map_err(|_| ())
555 }
556 }
557
558 impl ble_store::RecordReader for JournalFlash {
559 type Error = ();
560
561 fn read_record(&mut self, address: u32, bytes: &mut [u8]) -> Result<(), Self::Error> {
562 self.read(address, bytes).map_err(|_| ())
563 }
564 }
565
566 impl BleStore {
567 async fn mount(shared: &'static SharedFlash) -> Self {
568 let mut flash = shared.lock().await;
569 let mut latest: Option<(u32, Snapshot)> = None;
570 let mut read_failures = 0u8;
571 let mut valid_records = 0u8;
572 for page in [ble_store::PAGE0, ble_store::PAGE1] {
573 let mut address = page;
574 while address < page + ble_store::PAGE_SIZE {
575 let mut bytes = [0u8; ble_store::SLOT_SIZE];
576 if flash.read(address, &mut bytes).is_ok() {
577 if Snapshot::decode(&bytes).is_some() {
578 valid_records = valid_records.saturating_add(1);
579 }
580 latest = ble_store::consider_snapshot(latest, address, &bytes);
581 } else {
582 read_failures = read_failures.saturating_add(1);
583 }
584 address += ble_store::SLOT_SIZE as u32;
585 }
586 }
587 let (slot, snapshot) = latest
588 .map(|(slot, snapshot)| (Some(slot), snapshot))
589 .unwrap_or((None, Snapshot::empty()));
590 debug_log(format_args!(
591 "store mount valid-records={} read-failures={} selected-slot={:?} generation={} bonds={} pin={} local-irk={}",
592 valid_records,
593 read_failures,
594 slot,
595 snapshot.generation,
596 snapshot.bonds.len(),
597 snapshot.pin.is_some(),
598 snapshot.local_irk.is_some(),
599 ));
600 drop(flash);
601 Self {
602 flash: shared,
603 snapshot,
604 slot,
605 }
606 }
607
608 fn snapshot(&self) -> &Snapshot {
609 &self.snapshot
610 }
611
612 async fn persist(&mut self, mut snapshot: Snapshot) -> Result<(), ()> {
613 snapshot.generation = self.snapshot.generation.wrapping_add(1);
614 let mut flash = self.flash.lock().await;
615 let target = umsh_ulcp_runtime::journal::journal_write_target(
616 &mut *flash,
617 self.slot,
618 ble_store::PAGE0,
619 ble_store::SLOT_SIZE,
620 )
621 .await?;
622
623 let bytes = snapshot.encode();
624 debug_log(format_args!(
625 "store body-write begin generation={} target=0x{target:06x}",
626 snapshot.generation
627 ));
628 match ble_store::write_committed_record(&mut *flash, target, &bytes).await {
629 Ok(()) => debug_log(format_args!(
630 "store body-write=ok commit-write=ok target=0x{target:06x}"
631 )),
632 Err(ble_store::CommitError::Body(())) => {
633 debug_log(format_args!(
634 "store body-write=FAILED target=0x{target:06x}"
635 ));
636 return Err(());
637 }
638 Err(ble_store::CommitError::Commit(())) => {
639 debug_log(format_args!(
640 "store body-write=ok commit-write=FAILED target=0x{target:06x}"
641 ));
642 return Err(());
643 }
644 }
645 self.snapshot = snapshot;
646 self.slot = Some(target);
647 debug_log(format_args!(
648 "store commit generation={} slot=0x{:06x} bonds={} pin={} local_irk={}",
649 self.snapshot.generation,
650 target,
651 self.snapshot.bonds.len(),
652 self.snapshot.pin.is_some(),
653 self.snapshot.local_irk.is_some(),
654 ));
655 Ok(())
656 }
657
658 async fn set_pin(&mut self, pin: Option<u32>) -> Result<(), ()> {
659 let mut next = self.snapshot.clone();
660 next.pin = pin;
661 self.persist(next).await
662 }
663
664 async fn set_local_irk(&mut self, local_irk: [u8; 16]) -> Result<(), ()> {
665 if self.snapshot.local_irk == Some(local_irk) {
666 return Ok(());
667 }
668 let mut next = self.snapshot.clone();
669 next.local_irk = Some(local_irk);
670 self.persist(next).await
671 }
672
673 /// Persists `bond`, keeping the bond list LRU-ordered. Returns the
674 /// evicted bond, if inserting a new one at `MAX_BONDS` capacity
675 /// pushed out the least-recently-used entry.
676 async fn add_bond(&mut self, bond: &BondInformation) -> Result<Option<StoredBond>, ()> {
677 let stored = stored_bond(bond);
678 let mut next = self.snapshot.clone();
679 let outcome = ble_store::upsert_bond(&mut next.bonds, stored);
680 let evicted = match outcome {
681 ble_store::BondUpsert::Unchanged => return Ok(None),
682 ble_store::BondUpsert::Updated => None,
683 ble_store::BondUpsert::Inserted { evicted } => evicted,
684 };
685 self.persist(next).await?;
686 Ok(evicted)
687 }
688
689 /// Moves the bond matching `address_kind`/`address` to the MRU end
690 /// and persists it, if it isn't already there. Called on reconnect
691 /// via an existing bond, so the LRU order reflects actual use
692 /// rather than only pairing/re-pairing events.
693 async fn touch_bond(&mut self, address_kind: u8, address: [u8; 6]) -> Result<bool, ()> {
694 let mut next = self.snapshot.clone();
695 if !ble_store::touch_bond(&mut next.bonds, address_kind, address) {
696 return Ok(false);
697 }
698 self.persist(next).await?;
699 Ok(true)
700 }
701
702 async fn clear_security(&mut self) -> Result<(), ()> {
703 let mut next = Snapshot::empty();
704 next.generation = self.snapshot.generation;
705 next.local_irk = self.snapshot.local_irk;
706 self.persist(next).await
707 }
708 }
709
710 /// The stored protocol snapshot payload as read at boot.
711 type BootSnapshot = umsh_ulcp_runtime::journal::BootPayload;
712
713 /// This board's journal handle: the shared two-page rotating store
714 /// bound to the MPSL-coordinated flash.
715 type ProtoStore = umsh_ulcp_runtime::journal::ProtoStore<ThreadModeRawMutex, JournalFlash>;
716
717 #[cfg(feature = "t1000e")]
718 fn mapped_ux_preferences() -> Option<umsh_ux_tracker::state::UserPreferences> {
719 let mut latest: Option<(u32, proto_store::Stored)> = None;
720 for page in [
721 proto_store::UX_PAGE0,
722 proto_store::UX_PAGE0 + proto_store::PAGE_SIZE,
723 ] {
724 let mut address = page;
725 while address < page + proto_store::PAGE_SIZE {
726 let mut bytes = [0u8; proto_store::SLOT_SIZE];
727 // Internal flash is memory mapped. This early read happens
728 // before MPSL takes NVMC and never mutates flash.
729 unsafe {
730 core::ptr::copy_nonoverlapping(
731 address as *const u8,
732 bytes.as_mut_ptr(),
733 bytes.len(),
734 );
735 }
736 latest = proto_store::consider_record(latest, address, &bytes);
737 address += proto_store::SLOT_SIZE as u32;
738 }
739 }
740 let (_, stored) = latest?;
741 let proto_store::Record::Snapshot(payload) = stored.record else {
742 return None;
743 };
744 let mut preferences =
745 umsh_ux_tracker::state::UserPreferences::try_decode(*payload.first()?)?;
746 // Critical shutdown is a live protective condition mirrored in the
747 // retained register, not a durable user preference.
748 preferences.battery_critical = false;
749 Some(preferences)
750 }
751
752 /// Callers deliberately ignore the result: the preference is already
753 /// applied in RAM and mirrored in GPREGRET2, so a failed journal write
754 /// costs only durability across the next reset. There is no user-facing
755 /// fault channel, and confirmation feedback reflects the applied state,
756 /// not the flash commit.
757 #[cfg(feature = "t1000e")]
758 async fn persist_ux_preferences(
759 store: &mut ProtoStore,
760 mut preferences: umsh_ux_tracker::state::UserPreferences,
761 ) -> Result<(), ()> {
762 preferences.battery_critical = false;
763 store.persist(&[preferences.encode()]).await
764 }
765
766 // ─── Device-node counter persistence (plan increment 4) ─────────────────
767
768 // ─── Device-node counter persistence (plan increment 4) ─────────────────
769
770 // ─── Device-node counter persistence ────────────────────────────────
771
772 /// The device node's persisted frame counters, bound to this board's
773 /// flash. The map, the journal handle, and the `CounterStore` impl
774 /// are shared (`umsh_ulcp_runtime::node_counters`); only the flash
775 /// type and the journal's page are this board's.
776 pub type NodeCounters =
777 umsh_ulcp_runtime::node_counters::NodeCounters<ThreadModeRawMutex, JournalFlash>;
778 pub type NodeCountersMutex = umsh_ulcp_runtime::node_counters::NodeCountersMutex<
779 ThreadModeRawMutex,
780 ThreadModeRawMutex,
781 JournalFlash,
782 >;
783 pub type NodeCounterStore = umsh_ulcp_runtime::node_counters::NodeCounterStore<
784 ThreadModeRawMutex,
785 ThreadModeRawMutex,
786 JournalFlash,
787 >;
788
789 static NODE_COUNTERS_CELL: StaticCell<NodeCountersMutex> = StaticCell::new();
790
791 /// Initialize the (still journal-less) counter state. Call exactly
792 /// once, early in boot; the BLE image attaches the journal with
793 /// [`mount_node_counters`] before the device node comes up.
794 fn init_node_counters() -> &'static NodeCountersMutex {
795 NODE_COUNTERS_CELL.init(Mutex::new(NodeCounters::new()))
796 }
797
798 /// Mount the counter journal and load the persisted map.
799 async fn mount_node_counters(
800 counters: &'static NodeCountersMutex,
801 flash: &'static SharedFlash,
802 ) {
803 umsh_ulcp_runtime::node_counters::mount(counters, flash, proto_store::COUNTER_PAGE0).await
804 }
805
806 async fn prune_stale_tx_counters(counters: &'static NodeCountersMutex, public_key: &[u8; 32]) {
807 umsh_ulcp_runtime::node_counters::prune_stale_tx(counters, public_key).await
808 }
809
810 async fn clear_node_counters(counters: &'static NodeCountersMutex) {
811 umsh_ulcp_runtime::node_counters::clear(counters).await
812 }
813
814 fn stored_bond(bond: &BondInformation) -> StoredBond {
815 let address = bond.identity.addr.to_bytes();
816 StoredBond {
817 address_kind: address[0],
818 address: address[1..].try_into().unwrap(),
819 irk: bond.identity.irk.map(IdentityResolvingKey::to_le_bytes),
820 ltk: bond.ltk.to_le_bytes(),
821 security_level: match bond.security_level {
822 SecurityLevel::NoEncryption => 0,
823 SecurityLevel::Encrypted => 1,
824 SecurityLevel::EncryptedAuthenticated => 2,
825 },
826 is_bonded: bond.is_bonded,
827 }
828 }
829
830 fn bond_identity_is_persistable(bond: &BondInformation) -> bool {
831 let address = bond.identity.addr.to_bytes();
832 let public = address[0] & 1 == 0;
833 let random_static = address[1] & 0xc0 == 0xc0;
834 public || random_static || bond.identity.irk.is_some()
835 }
836
837 fn trouble_bond(bond: &StoredBond) -> Option<BondInformation> {
838 let mut raw = bond.address;
839 raw.reverse();
840 let identity = Identity {
841 addr: Address::new(AddrKind::new(bond.address_kind), BdAddr::new(raw)),
842 irk: bond.irk.and_then(IdentityResolvingKey::from_le_bytes),
843 };
844 let security_level = match bond.security_level {
845 0 => SecurityLevel::NoEncryption,
846 1 => SecurityLevel::Encrypted,
847 2 => SecurityLevel::EncryptedAuthenticated,
848 _ => return None,
849 };
850 Some(BondInformation::new(
851 identity,
852 LongTermKey::from_le_bytes(bond.ltk),
853 security_level,
854 bond.is_bonded,
855 ))
856 }
857
858 fn ble_identity_address() -> Address {
859 let low = embassy_nrf::pac::FICR.deviceaddr(0).read().to_le_bytes();
860 let high = embassy_nrf::pac::FICR.deviceaddr(1).read().to_le_bytes();
861 let mut address = [low[0], low[1], low[2], low[3], high[0], high[1]];
862 address[5] |= 0xc0;
863 Address::random(address)
864 }
865
866 // ─── Static shared state ─────────────────────────────────────────────────
867
868 /// Channels shared between the radio runner and the radio mux, which
869 /// is the runner's only client.
870 type RadioCh = umsh_radio_loraphy::Channels<ThreadModeRawMutex, 4, 2>;
871 static RADIO_CH: RadioCh = RadioCh::new();
872
873 /// The session's virtual radio endpoint (mux client A). The device
874 /// node's endpoint (client B) lives in `device_node::NODE_CH`.
875 static SESSION_CH: RadioCh = RadioCh::new();
876 static MUX_CLIENTS: [&RadioCh; 2] = [&SESSION_CH, &super::device_node::NODE_CH];
877
878 /// Runtime radio settings pushed by the session to the runner.
879 static DEVICE_CTL: DeviceControl<ThreadModeRawMutex> = DeviceControl::new();
880
881 /// Framing-free receive path and connection edges into the shared
882 /// ULCP driver (`InEvent`/`FrameBuf` and the queue types live there).
883 static INPUT_CH: InputChannel<ThreadModeRawMutex> = InputChannel::new();
884 type FrameBuf = driver::FrameBuf;
885 const FRAME_IN_MAX: usize = driver::FRAME_IN_MAX;
886
887 /// Outbound frame queues: `wired` drained by output_task (USB-CDC),
888 /// `ble` by the GATT connection writer.
889 static OUT_CH: TransportChannels<ThreadModeRawMutex> = TransportChannels::new();
890
891 type DeviceName = heapless::Vec<u8, { MAX_DEVICE_NAME_LEN }>;
892 static DEVICE_NAME: Mutex<ThreadModeRawMutex, DeviceName> = Mutex::new(DeviceName::new());
893
894 /// Snapshot the live device name for the device node's
895 /// advertisements. Falls back to the (FICR-suffixed) default until
896 /// the session publishes a name at boot.
897 pub(crate) async fn device_name_snapshot() -> DeviceName {
898 let current = DEVICE_NAME.lock().await;
899 if current.is_empty() {
900 let mut name = DeviceName::new();
901 let _ = name.extend_from_slice(default_device_name().as_bytes());
902 name
903 } else {
904 current.clone()
905 }
906 }
907 static DEVICE_NAME_CHANGED: Signal<ThreadModeRawMutex, ()> = Signal::new();
908
909 /// The GAP Device Name value that clients may hold a stale copy of.
910 ///
911 /// Set when the device is renamed and cleared once a Service Changed
912 /// indication has gone out. A rename that happens with no one connected
913 /// is therefore announced to the next client instead of being lost.
914 /// Known gap: a second bonded peer that is absent for the rename *and*
915 /// for the connection that consumes this flag keeps its cached name
916 /// until it reads the characteristic again. Closing that needs the
917 /// pending-indication state to live per bond, in the bond store.
918 static GATT_NAME_STALE: AtomicBool = AtomicBool::new(false);
919
920 /// Whether a device name has been published since boot.
921 ///
922 /// The first publication is the saved name being restored as the radio
923 /// configuration is applied, not a rename. Treating it as one would mark
924 /// the database stale on every power cycle and make every bonded peer
925 /// re-discover on its next connection.
926 static DEVICE_NAME_PUBLISHED: AtomicBool = AtomicBool::new(false);
927
928 /// GAP's own bound on the Device Name value, which is shorter than the
929 /// ULCP device-name limit.
930 type GapDeviceName = heapless09::Vec<u8, { gap::DEVICE_NAME_MAX_LENGTH }>;
931
932 /// The platform battery source behind `Effect::SampleBattery`: one
933 /// async sample operation per board profile, returning the fields
934 /// that board's `SessionConfig::battery` advertises.
935 ///
936 /// T-1000E: a request/reply round trip into the BSP battery monitor
937 /// — the sole SAADC and sensor-rail owner — which runs its normal
938 /// gated sample/classify/publish iteration early and replies with
939 /// the millivolt reading and UX classification. The timeout covers
940 /// the monitor having exited for critical-battery shutdown.
941 #[cfg(feature = "cap-battery-saadc")]
942 async fn sample_battery_snapshot() -> Result<umsh_ulcp::battery::BatteryStatus, ()> {
943 let sample =
944 embassy_time::with_timeout(Duration::from_secs(2), board_power::sample_battery())
945 .await
946 .map_err(|_| ())?;
947 battery_snapshot(sample)
948 }
949
950 /// Reduce one BSP battery sample to the protocol snapshot this board's
951 /// `SessionConfig::battery` advertises.
952 ///
953 /// Shared by the on-demand read (`Effect::SampleBattery`) and the
954 /// asynchronous publication (`DeviceEnv::battery_event`) so the two can
955 /// never report the same measurement differently.
956 #[cfg(feature = "cap-battery-saadc")]
957 fn battery_snapshot(
958 sample: board_power::BatterySample,
959 ) -> Result<umsh_ulcp::battery::BatteryStatus, ()> {
960 use umsh_ulcp::battery::{BatteryChargeState, BatteryStatus};
961 use umsh_ux_tracker::battery::ChargeClass;
962 // Low and critical are UX presentation policy, not charge states;
963 // `charge_class` collapses all three unpowered classifications.
964 let charge_state = match umsh_ux_tracker::battery::charge_class(sample.state) {
965 ChargeClass::Charging => BatteryChargeState::Charging,
966 ChargeClass::Charged => BatteryChargeState::Charged,
967 ChargeClass::Discharging => BatteryChargeState::Discharging,
968 };
969 // The level is reported by its absence when the estimator has
970 // none to give — before its first quiet sample, and for as long as
971 // the pack is charging on a board whose charger reports no
972 // completion. Voltage and charge state still mean something in
973 // both cases, so the snapshot goes out carrying what it can.
974 Ok(BatteryStatus {
975 voltage_mv: Some(sample.battery_mv),
976 level_percent: sample.level_percent,
977 charge_state: Some(charge_state),
978 })
979 }
980
981 /// T-Echo: `BatteryFields::NONE` means the session answers the empty
982 /// value without emitting the effect, so this is unreachable; a
983 /// failure keeps any future misrouting honest.
984 #[cfg(not(feature = "cap-battery-saadc"))]
985 async fn sample_battery_snapshot() -> Result<umsh_ulcp::battery::BatteryStatus, ()> {
986 Err(())
987 }
988
989 /// The platform light source behind `Effect::SampleIlluminance`: a
990 /// request/reply round trip into the same BSP monitor that owns the
991 /// SAADC, which raises both sensor enables, settles, averages, and
992 /// replies in millilux. The timeout covers the monitor having exited
993 /// for critical-battery shutdown — a device on its way down reports
994 /// no reading rather than hanging the transaction.
995 #[cfg(feature = "cap-illuminance")]
996 async fn sample_illuminance_millilux() -> Option<u32> {
997 embassy_time::with_timeout(
998 Duration::from_secs(2),
999 umsh_bsp_t1000e::light::sample_illuminance(),
1000 )
1001 .await
1002 .ok()
1003 }
1004
1005 /// Published session epoch, checked by each transport at framing edges.
1006 static SESSION_GEN: AtomicU32 = AtomicU32::new(0);
1007
1008 /// Previous boot's last breadcrumb stage (temporary freeze
1009 /// diagnostics; surfaced in the version string as `crumb=N`).
1010 static PREV_BOOT_CRUMB: AtomicU8 = AtomicU8::new(0);
1011 /// Previous boot's heartbeat-iteration count (~50 ms each;
1012 /// surfaced as `beats=N`).
1013 static PREV_BOOT_BEATS: AtomicU16 = AtomicU16::new(0);
1014 /// Raw `RESETREAS` bits captured at this boot (surfaced as `rr=0x…`
1015 /// so a simultaneous LOCKUP+DOG cannot hide behind the DOG-first
1016 /// reset-reason mapping).
1017 static BOOT_RESETREAS: AtomicU32 = AtomicU32::new(0);
1018 /// Previous boot's watchdog-timeout capture (stacked PC/LR/xPSR of
1019 /// the code executing ~61 µs before the watchdog reset).
1020 static PREV_WDT_PC: AtomicU32 = AtomicU32::new(0);
1021 static PREV_WDT_LR: AtomicU32 = AtomicU32::new(0);
1022 static PREV_WDT_PSR: AtomicU32 = AtomicU32::new(0);
1023 /// Previous boot's PC-ring sample count (proves the 1 kHz sampler
1024 /// ran; surfaced as `rc=N`).
1025 static PREV_RING_COUNT: AtomicU16 = AtomicU16::new(0);
1026
1027 /// RAM-only until BLE persistence lands. `u32::MAX` means unset.
1028 static PAIRING_PIN: AtomicU32 = AtomicU32::new(u32::MAX);
1029 static BLE_BONDS_AT_BOOT: AtomicU8 = AtomicU8::new(0);
1030 static BLE_BOND_COUNT: AtomicU8 = AtomicU8::new(0);
1031
1032 /// How far the BLE link has got, for the status page. Nothing is
1033 /// connected until a GATT connection is accepted; "attached" means the
1034 /// client has subscribed to the ULCP notification characteristic and
1035 /// is therefore actually talking to us, not merely nearby.
1036 ///
1037 /// Same encoding as the Heltec V3's, so both families read the state
1038 /// out of their `ui_status` the same way.
1039 static BLE_LINK: AtomicU8 = AtomicU8::new(BLE_LINK_NONE);
1040 const BLE_LINK_NONE: u8 = 0;
1041 const BLE_LINK_CONNECTED: u8 = 1;
1042 const BLE_LINK_ATTACHED: u8 = 2;
1043
1044 static PAIRING_MODE: AtomicBool = AtomicBool::new(true);
1045 static PAIRING_LOCKED_OUT: AtomicBool = AtomicBool::new(false);
1046 static PAIRING_FAILURES: AtomicU8 = AtomicU8::new(0);
1047 /// Set when the T-1000E user button is held through power-on. This is
1048 /// deliberately independent of bond count: physical presence opens one
1049 /// pairing window even when existing bonds are present.
1050 static FORCE_PAIRING_AT_BOOT: AtomicBool = AtomicBool::new(false);
1051 #[cfg(feature = "ble-store-fault-inject")]
1052 static BLE_STORE_FAULT_ARMED: AtomicBool = AtomicBool::new(false);
1053 static PAIRING_CONFIG_CH: Channel<ThreadModeRawMutex, Option<u32>, 1> = Channel::new();
1054 static PAIRING_CONFIG_ACK: Signal<ThreadModeRawMutex, bool> = Signal::new();
1055 static PAIRING_MODE_REQUEST: Signal<ThreadModeRawMutex, ()> = Signal::new();
1056 static PAIRING_TIMER_RESET: Signal<ThreadModeRawMutex, ()> = Signal::new();
1057 static BLE_WIPE_REQUEST: Signal<ThreadModeRawMutex, ()> = Signal::new();
1058 #[cfg(feature = "has-display")]
1059 static UI_INPUT_CH: Channel<ThreadModeRawMutex, UiInput, 8> = Channel::new();
1060 static UI_REFRESH: Signal<ThreadModeRawMutex, ()> = Signal::new();
1061 static UI_NOTICE: Signal<ThreadModeRawMutex, UiNotice> = Signal::new();
1062 #[cfg(feature = "has-display")]
1063 static DISPLAY_SHUTDOWN: Signal<ThreadModeRawMutex, ()> = Signal::new();
1064 #[cfg(feature = "has-display")]
1065 static DISPLAY_SHUTDOWN_DONE: Signal<ThreadModeRawMutex, ()> = Signal::new();
1066 /// 0 = normal heartbeat, 1 = pairing mode, 2 = BLE state wiped.
1067 static BLE_LED_MODE: AtomicU8 = AtomicU8::new(0);
1068
1069 /// Whether a locate alert (`PROP_ALERT`) is running. The session is
1070 /// authoritative; this is its board-side mirror, read by the LED
1071 /// task to drive the blink and by the button task, which swallows
1072 /// the press that cancels an alert.
1073 static ALERT_ACTIVE: AtomicBool = AtomicBool::new(false);
1074 /// Wakes the LED task on a locate-alert edge.
1075 static ALERT_CHANGED: Signal<ThreadModeRawMutex, ()> = Signal::new();
1076 /// The same edge, for the display task. A `Signal` has one useful
1077 /// consumer, so each task that must react promptly gets its own.
1078 #[cfg(feature = "has-display")]
1079 static UI_ALERT_CHANGED: Signal<ThreadModeRawMutex, ()> = Signal::new();
1080
1081 /// Apply a `PROP_ALERT` transition to the board's indicators.
1082 ///
1083 /// Idempotent — the session emits the effect on every transition,
1084 /// including ones that change nothing.
1085 fn set_alert_indication(active: bool) {
1086 if ALERT_ACTIVE.swap(active, Ordering::AcqRel) == active {
1087 return;
1088 }
1089 ALERT_CHANGED.signal(());
1090 // Boards with a sounder make the noise too; on the others the
1091 // LED blink (plus whatever the panel shows) is the whole alert.
1092 #[cfg(feature = "t1000e")]
1093 umsh_bsp_t1000e::indicator::BUZZER_ALERT_SET.signal(active);
1094 #[cfg(feature = "cap-buzzer")]
1095 umsh_bsp_wio_tracker_l1::buzzer::BUZZER_ALERT_SET.signal(active);
1096 #[cfg(feature = "has-display")]
1097 UI_ALERT_CHANGED.signal(());
1098 #[cfg(feature = "display-epd")]
1099 BACKLIGHT_CHANGED.signal(());
1100 }
1101
1102 /// Whether the capacitive touch button is currently held.
1103 ///
1104 /// The touch button and the locate alert both want the backlight, so
1105 /// neither drives the pin directly; they publish their demand here
1106 /// and [`backlight_task`] arbitrates.
1107 #[cfg(feature = "display-epd")]
1108 static BACKLIGHT_TOUCH: AtomicBool = AtomicBool::new(false);
1109 /// Wakes [`backlight_task`] when either demand changes.
1110 #[cfg(feature = "display-epd")]
1111 static BACKLIGHT_CHANGED: Signal<ThreadModeRawMutex, ()> = Signal::new();
1112
1113 /// Whether the emissive panel has lapsed dark.
1114 ///
1115 /// Published by the display task and read by the button task, which
1116 /// latches it on the press edge so the press that lights the panel is
1117 /// not also treated as navigation. Boards with a bistable panel never
1118 /// set it: they have nothing to wake.
1119 #[cfg(feature = "display-oled")]
1120 static SCREEN_OFF: AtomicBool = AtomicBool::new(false);
1121 /// Asks the emissive panel to come back on. Distinct from
1122 /// [`UI_REFRESH`], which changes what is drawn but must never light a
1123 /// panel the user did not touch.
1124 #[cfg(feature = "display-oled")]
1125 static UI_WAKE: Signal<ThreadModeRawMutex, ()> = Signal::new();
1126
1127 /// Whether a locate alert is running.
1128 fn alert_active() -> bool {
1129 ALERT_ACTIVE.load(Ordering::Acquire)
1130 }
1131
1132 /// USB protocol attachment suppresses BLE advertising. The signal wakes a
1133 /// pending advertiser/connection so it can apply the atomic policy.
1134 static ADV_ALLOWED: AtomicBool = AtomicBool::new(true);
1135 static ADV_POLICY_CHANGED: Signal<ThreadModeRawMutex, ()> = Signal::new();
1136
1137 #[cfg(feature = "ble-debug")]
1138 type DebugLine = heapless::String<192>;
1139 #[cfg(feature = "ble-debug")]
1140 // 128 deep so the whole boot window (including trouble-host's resolving-list
1141 // diagnostics) survives in the buffer until a serial reader attaches and
1142 // starts draining; the output task only drains after DTR. ble-debug only.
1143 static DEBUG_CH: Channel<ThreadModeRawMutex, DebugLine, 128> = Channel::new();
1144 #[cfg(feature = "ble-debug")]
1145 static DEBUG_DROPPED: AtomicU32 = AtomicU32::new(0);
1146
1147 pub(crate) fn debug_log(args: core::fmt::Arguments<'_>) {
1148 #[cfg(feature = "ble-debug")]
1149 {
1150 let mut line = DebugLine::new();
1151 let dropped = DEBUG_DROPPED.swap(0, Ordering::AcqRel);
1152 if write!(line, "[{:>8} ms] ", Instant::now().as_millis()).is_err()
1153 || (dropped != 0 && write!(line, "[debug-dropped={dropped}] ").is_err())
1154 || line.write_fmt(args).is_err()
1155 || line.push_str("\r\n").is_err()
1156 {
1157 DEBUG_DROPPED.fetch_add(dropped.saturating_add(1), Ordering::AcqRel);
1158 return;
1159 }
1160 if DEBUG_CH.try_send(line).is_err() {
1161 DEBUG_DROPPED.fetch_add(dropped.saturating_add(1), Ordering::AcqRel);
1162 }
1163 }
1164 #[cfg(not(feature = "ble-debug"))]
1165 let _ = args;
1166 }
1167
1168 /// Bridges the `log` facade used by foreign crates (notably
1169 /// trouble-host's resolving-list and SMP diagnostics) into the same
1170 /// USB-serial debug channel as [`debug_log`], inheriting its
1171 /// timestamping and drop accounting.
1172 #[cfg(feature = "ble-debug")]
1173 struct DebugChannelLogger;
1174
1175 #[cfg(feature = "ble-debug")]
1176 impl log::Log for DebugChannelLogger {
1177 fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool {
1178 true
1179 }
1180
1181 fn log(&self, record: &log::Record<'_>) {
1182 debug_log(format_args!(
1183 "{} {}: {}",
1184 record.level(),
1185 record.target(),
1186 record.args(),
1187 ));
1188 }
1189
1190 fn flush(&self) {}
1191 }
1192
1193 #[cfg(feature = "ble-debug")]
1194 static DEBUG_LOGGER: DebugChannelLogger = DebugChannelLogger;
1195
1196 /// Installs [`DebugChannelLogger`] as the global `log` sink. Idempotent:
1197 /// `set_logger` may only succeed once, so a repeat call is ignored. Level
1198 /// is capped at Debug to capture trouble-host's `[host] …` diagnostics
1199 /// and warnings without the per-packet Trace flood.
1200 #[cfg(feature = "ble-debug")]
1201 fn init_foreign_crate_logging() {
1202 let _ = log::set_logger(&DEBUG_LOGGER);
1203 log::set_max_level(log::LevelFilter::Debug);
1204 }
1205
1206 #[cfg(feature = "ble-debug")]
1207 fn trouble_security_trace(event: SecurityTrace) {
1208 debug_log(format_args!(
1209 "smp {:?} opcode=0x{:02x} detail={:02x?}",
1210 event.direction,
1211 event.command,
1212 &event.detail[..usize::from(event.detail_len)],
1213 ));
1214 }
1215
1216 #[cfg(feature = "ble-debug")]
1217 fn trouble_connection_trace(event: ConnectionTrace) {
1218 debug_log(format_args!("trouble connection {event:?}"));
1219 }
1220
1221 #[cfg(feature = "ble-debug")]
1222 fn trouble_security_diagnostic_trace(event: SecurityDiagnosticTrace) {
1223 debug_log(format_args!("trouble security {event:?}"));
1224 }
1225
1226 /// Fired by button_task on the power-off hold; consumed by the
1227 /// board's shutdown task, which also watches the BSP's own signal
1228 /// (the low-battery cutoff).
1229 #[cfg(any(feature = "system-off-techo", feature = "system-off-wio"))]
1230 static SHUTDOWN_SIGNAL: Signal<ThreadModeRawMutex, ()> = Signal::new();
1231
1232 // ─── Outgoing frame limits ───────────────────────────────────────────────
1233
1234 const WIRE_MAX: usize = hdlc::max_encoded_len(driver::FRAME_OUT_MAX);
1235
1236 fn set_advertising_allowed(allowed: bool) {
1237 let previous = ADV_ALLOWED.swap(allowed, Ordering::AcqRel);
1238 debug_log(format_args!(
1239 "advertising policy set previous={} allowed={} changed={}",
1240 previous,
1241 allowed,
1242 previous != allowed,
1243 ));
1244 ADV_POLICY_CHANGED.signal(());
1245 }
1246
1247 fn apply_pairing_gate<C: Controller, P: PacketPool>(stack: &Stack<'_, C, P>) {
1248 let pin_configured = PAIRING_PIN.load(Ordering::Acquire) != u32::MAX;
1249 let bonds = usize::from(BLE_BOND_COUNT.load(Ordering::Acquire));
1250 let enabled = pairing_enabled(
1251 PAIRING_MODE.load(Ordering::Acquire),
1252 pin_configured,
1253 PAIRING_LOCKED_OUT.load(Ordering::Acquire),
1254 );
1255 stack.set_pairing_enabled(enabled);
1256 debug_log(format_args!(
1257 "pairing gate enabled={} mode={} pin={} locked={} failures={} bonds={}/{}",
1258 enabled,
1259 PAIRING_MODE.load(Ordering::Acquire),
1260 pin_configured,
1261 PAIRING_LOCKED_OUT.load(Ordering::Acquire),
1262 PAIRING_FAILURES.load(Ordering::Acquire),
1263 bonds,
1264 ble_store::MAX_BONDS,
1265 ));
1266 }
1267
1268 fn pairing_runtime() -> PairingRuntime {
1269 PairingRuntime {
1270 pairing_mode: PAIRING_MODE.load(Ordering::Acquire),
1271 failures: PAIRING_FAILURES.load(Ordering::Acquire),
1272 locked_out: PAIRING_LOCKED_OUT.load(Ordering::Acquire),
1273 }
1274 }
1275
1276 fn publish_pairing_runtime(state: PairingRuntime) {
1277 PAIRING_MODE.store(state.pairing_mode, Ordering::Release);
1278 PAIRING_FAILURES.store(state.failures, Ordering::Release);
1279 PAIRING_LOCKED_OUT.store(state.locked_out, Ordering::Release);
1280 UI_REFRESH.signal(());
1281 }
1282
1283 async fn persist_bond(
1284 store: &BleStoreMutex,
1285 bond: &BondInformation,
1286 ) -> Result<(usize, Option<StoredBond>), ()> {
1287 let mut store = store.lock().await;
1288 let evicted = store.add_bond(bond).await?;
1289 Ok((store.snapshot().bonds.len(), evicted))
1290 }
1291
1292 /// Drops an LRU-evicted bond from the live trouble bond table so the
1293 /// evicted peer can't keep reconnecting as "bonded" this power cycle
1294 /// using stale in-RAM keys after being pushed out of durable storage.
1295 fn forget_evicted_bond<C: Controller, P: PacketPool>(
1296 stack: &Stack<'_, C, P>,
1297 evicted: Option<StoredBond>,
1298 ) {
1299 let Some(evicted) = evicted else {
1300 return;
1301 };
1302 let Some(evicted_info) = trouble_bond(&evicted) else {
1303 return;
1304 };
1305 match stack.remove_bond_information(evicted_info.identity) {
1306 Ok(()) => debug_log(format_args!("lru bond evict remove=ok")),
1307 Err(error) => debug_log(format_args!("lru bond evict remove=FAILED error={error:?}")),
1308 }
1309 }
1310
1311 fn build_sdc<'d, const N: usize>(
1312 p: sdc::Peripherals<'d>,
1313 rng: &'d mut rng::Rng<Async>,
1314 mpsl: &'d MultiprotocolServiceLayer,
1315 mem: &'d mut sdc::Mem<N>,
1316 ) -> Result<sdc::SoftdeviceController<'d>, sdc::Error> {
1317 sdc::Builder::new()?
1318 .support_adv()
1319 .support_peripheral()
1320 // LE Privacy enables the controller resolving list and address
1321 // resolution. Without it, trouble-host's boot-time
1322 // LeSetAddrResolutionEnable / LeAddDeviceToResolvingList commands
1323 // fail as unsupported, the resolving list stays empty, and a
1324 // bonded central that reconnects with a rotated resolvable private
1325 // address (iOS rotates its RPA ~every 15 min) never resolves to
1326 // its bond — so the link never re-encrypts and wedges the single
1327 // peripheral slot as an unusable NoEncryption connection.
1328 .support_le_privacy()
1329 .peripheral_count(1)?
1330 .buffer_cfg(
1331 SDC_PACKET_SIZE,
1332 SDC_PACKET_SIZE,
1333 BLE_L2CAP_TXQ,
1334 BLE_L2CAP_RXQ,
1335 )?
1336 .build(p, rng, mpsl, mem)
1337 }
1338
1339 /// Board environment for the shared ULCP driver
1340 /// (`umsh_ulcp_runtime::driver`): persistence, entropy, pairing,
1341 /// and indicator couplings. The former `cfg(feature = "t1000e")` forks
1342 /// inside the session loop live here as trait-method overrides; the
1343 /// T-Echo build keeps the driver's no-op defaults for the indicator
1344 /// and load hooks.
1345 struct BoardDeviceEnv {
1346 proto_store: ProtoStore,
1347 identity_store: ProtoStore,
1348 identity_rng: IdentityRng,
1349 node_counters: &'static NodeCountersMutex,
1350 /// Announce-worthy battery measurements from the BSP monitor, for
1351 /// unsolicited `PROP_BATTERY` publication. The monitor owns the
1352 /// cadence and the change policy; this only forwards.
1353 #[cfg(feature = "cap-battery-saadc")]
1354 battery: embassy_sync::watch::DynReceiver<'static, board_power::BatterySample>,
1355 /// Positioning changes worth publishing unasked. The runtime's
1356 /// GNSS sink owns the policy — a stationary receiver produces a
1357 /// fix a second and almost none of them are news — so this only
1358 /// forwards what it decided to raise.
1359 #[cfg(feature = "cap-gnss")]
1360 gnss_announce: umsh_ulcp_runtime::gnss::Announcer,
1361 }
1362
1363 impl DeviceEnv for BoardDeviceEnv {
1364 async fn persist_snapshot(&mut self, bytes: &[u8]) -> Result<(), ()> {
1365 self.proto_store.persist(bytes).await
1366 }
1367
1368 async fn clear_snapshot(&mut self) -> Result<(), ()> {
1369 self.proto_store.clear().await
1370 }
1371
1372 async fn older_snapshot(&mut self, out: &mut [u8]) -> Option<usize> {
1373 self.proto_store.older_snapshot(out).await
1374 }
1375
1376 async fn sign_identity(&mut self, out: &mut [u8]) -> Option<usize> {
1377 super::device_node::sign_identity_blob(out).await
1378 }
1379
1380 /// A rejected snapshot on an unattended repeater reaches nobody
1381 /// over the protocol, so raise the board's local indication too.
1382 /// `fell_back` distinguishes "running on stale configuration"
1383 /// from "booted bare".
1384 fn report_snapshot_rejected(&mut self, fell_back: bool) {
1385 debug_log(format_args!(
1386 "proto-store snapshot rejected fell-back={fell_back}"
1387 ));
1388 #[cfg(feature = "t1000e")]
1389 umsh_bsp_t1000e::indicator::request_attention();
1390 }
1391
1392 async fn persist_identity(&mut self, bytes: &[u8]) -> Result<(), ()> {
1393 self.identity_store.persist(bytes).await
1394 }
1395
1396 async fn clear_identity(&mut self) -> Result<(), ()> {
1397 self.identity_store.clear().await
1398 }
1399
1400 async fn clear_counters(&mut self) {
1401 clear_node_counters(self.node_counters).await;
1402 }
1403
1404 fn fill_secret(&mut self, secret: &mut [u8; 32]) -> Result<(), ()> {
1405 // TRNG-seeded ChaCha20 CSPRNG (the RNG peripheral belongs to
1406 // the SDC at runtime); infallible once seeded.
1407 rand_core::RngCore::fill_bytes(&mut self.identity_rng, secret);
1408 Ok(())
1409 }
1410
1411 async fn sample_battery(&mut self) -> Result<umsh_ulcp::battery::BatteryStatus, ()> {
1412 sample_battery_snapshot().await
1413 }
1414
1415 #[cfg(feature = "cap-illuminance")]
1416 async fn sample_illuminance(&mut self) -> Option<u32> {
1417 sample_illuminance_millilux().await
1418 }
1419
1420 /// Forward the monitor's announce-worthy samples. A sample that
1421 /// cannot be reduced to this board's advertised field set is
1422 /// skipped rather than published — the same fail-closed rule the
1423 /// on-demand read applies.
1424 ///
1425 /// `Watch::changed` is cancellation-safe (the receiver remembers
1426 /// which value it last observed), which this hook requires: the
1427 /// driver's select drops the future whenever another arm wins.
1428 #[cfg(feature = "cap-battery-saadc")]
1429 async fn battery_event(&mut self) -> umsh_ulcp::battery::BatteryStatus {
1430 loop {
1431 let sample = self.battery.changed().await;
1432 if let Ok(snapshot) = battery_snapshot(sample) {
1433 return snapshot;
1434 }
1435 }
1436 }
1437
1438 async fn read_time(&mut self) -> Option<u32> {
1439 umsh_hal::wall_clock::now()
1440 }
1441
1442 /// A host wrote `PROP_TIME`. An operator outranks every other
1443 /// source, including a receiver whose time is being distrusted —
1444 /// distrusting the sky is precisely why somebody would set the
1445 /// clock by hand.
1446 ///
1447 /// The empty write is not a failure to parse: it is the host
1448 /// saying the device should go back to not knowing, which is what
1449 /// takes the clock off a board's display.
1450 async fn apply_time(&mut self, epoch: Option<u32>) {
1451 match epoch {
1452 Some(seconds) => {
1453 umsh_hal::wall_clock::set_manual(seconds);
1454 }
1455 None => umsh_hal::wall_clock::clear(),
1456 }
1457 // The clock appearing or vanishing is a visible change the
1458 // user asked for, so redraw now rather than at whatever the
1459 // next event happens to be.
1460 UI_REFRESH.signal(());
1461 }
1462
1463 /// The receiver's current view. Cached by the runtime rather than
1464 /// re-read from the receiver, because "what did it last say" is
1465 /// the only question a UART emitting one cycle a second can
1466 /// answer promptly.
1467 #[cfg(feature = "cap-gnss")]
1468 async fn sample_gnss(&mut self) -> Result<umsh_ulcp::gnss::GnssSnapshot, ()> {
1469 Ok(umsh_ulcp_runtime::gnss::snapshot())
1470 }
1471
1472 /// Everything this board publishes unasked, on one select arm.
1473 ///
1474 /// The driver has exactly one, because a hook per property would
1475 /// need one `&mut self` borrow apiece. These are disjoint fields
1476 /// rather than three method calls, which is what makes selecting
1477 /// over them legal.
1478 #[cfg(feature = "cap-gnss")]
1479 async fn publish_event(&mut self) -> driver::PublishEvent {
1480 loop {
1481 #[cfg(feature = "cap-battery-saadc")]
1482 let event = select(self.battery.changed(), self.gnss_announce.changed()).await;
1483 #[cfg(not(feature = "cap-battery-saadc"))]
1484 let event = Either::Second(self.gnss_announce.changed().await) as Either<(), _>;
1485
1486 match event {
1487 #[cfg(feature = "cap-battery-saadc")]
1488 Either::First(sample) => {
1489 // A sample this board cannot reduce to its
1490 // advertised field set is skipped rather than
1491 // published — the same fail-closed rule the
1492 // on-demand read applies.
1493 if let Ok(snapshot) = battery_snapshot(sample) {
1494 return driver::PublishEvent::Battery(snapshot);
1495 }
1496 }
1497 #[cfg(not(feature = "cap-battery-saadc"))]
1498 Either::First(()) => unreachable!("no battery source on this board"),
1499 Either::Second(umsh_ulcp_runtime::gnss::Announce::Gnss(key, snapshot)) => {
1500 return driver::PublishEvent::Gnss(key, snapshot);
1501 }
1502 Either::Second(umsh_ulcp_runtime::gnss::Announce::Time(epoch)) => {
1503 return driver::PublishEvent::Time(epoch);
1504 }
1505 }
1506 }
1507 }
1508
1509 async fn apply_pairing_pin(&mut self, pin: Option<u32>) -> bool {
1510 PAIRING_CONFIG_CH.send(pin).await;
1511 PAIRING_CONFIG_ACK.wait().await
1512 }
1513
1514 async fn factory_reset(&mut self) -> ! {
1515 // Erase the entire non-volatile storage region in one sweep.
1516 // Every persistent journal lives in this contiguous window
1517 // (see `ble_store` / memory.x): BLE bonds + pairing PIN + local
1518 // IRK, the saved provisioning snapshot, the device identity,
1519 // UX state, and the frame-counter boundaries. Wiping the flash
1520 // and rebooting is a complete factory reset — every subsystem
1521 // remounts from erased flash on boot, so no live in-RAM table
1522 // (BLE bonds included) has to be touched here.
1523 //
1524 // The full reserved region, not just the pages currently in
1525 // use, so a future journal added inside it is covered too.
1526 const NV_REGION_START: u32 = ble_store::PAGE0; // 0x000E_4000
1527 const NV_REGION_END: u32 = 0x000F_4000;
1528 debug_log(format_args!("FACTORY RESET: erasing NV region + reboot"));
1529 {
1530 let mut flash = self.proto_store.flash().lock().await;
1531 let mut page = NV_REGION_START;
1532 while page < NV_REGION_END {
1533 // Best-effort: a page that fails to erase is superseded
1534 // by the reboot's fresh mount anyway, and there is no
1535 // host left to report to (the link drops on reset).
1536 let _ = flash.erase(page, page + ble_store::PAGE_SIZE).await;
1537 page += ble_store::PAGE_SIZE;
1538 }
1539 }
1540 // Discards all in-RAM state and remounts factory-fresh.
1541 cortex_m::peripheral::SCB::sys_reset()
1542 }
1543
1544 fn set_advertising_allowed(&mut self, allowed: bool) {
1545 // ble-debug builds keep advertising open regardless of the
1546 // arbitration policy so the diagnostic console stays reachable.
1547 #[cfg(feature = "ble-debug")]
1548 let allowed = {
1549 let _ = allowed;
1550 true
1551 };
1552 set_advertising_allowed(allowed);
1553 }
1554
1555 async fn publish_device_name(&mut self, name: &str) {
1556 let bytes = name.as_bytes();
1557 let mut current = DEVICE_NAME.lock().await;
1558 if current.as_slice() == bytes {
1559 return;
1560 }
1561 current.clear();
1562 if current.extend_from_slice(bytes).is_ok() {
1563 if DEVICE_NAME_PUBLISHED.swap(true, Ordering::AcqRel) {
1564 GATT_NAME_STALE.store(true, Ordering::Release);
1565 }
1566 DEVICE_NAME_CHANGED.signal(());
1567 super::device_node::set_device_name(bytes);
1568 }
1569 }
1570
1571 fn publish_dev_domain(&mut self, snapshot: driver::DevDomainSnapshot) {
1572 // The zone and the positioning policy ride the device-domain
1573 // mirror, so a host write, a boot restore, and a `CMD_RST`
1574 // all reach the clock and the receiver by the same path —
1575 // and neither needs anything to remember to push it.
1576 umsh_hal::wall_clock::set_tz(snapshot.tz_offset_min);
1577 #[cfg(feature = "cap-gnss")]
1578 umsh_ulcp_runtime::gnss::configure(
1579 snapshot.gnss_enabled,
1580 umsh_ulcp_runtime::gnss::Policy {
1581 trust_time: snapshot.gnss_time_trust,
1582 update_identity: snapshot.gnss_ident_update,
1583 identity_precision: snapshot.gnss_ident_precision,
1584 },
1585 );
1586 super::device_node::DEV_SYNC.signal(snapshot);
1587 }
1588
1589 #[cfg(feature = "t1000e")]
1590 fn request_attention(&mut self) {
1591 umsh_bsp_t1000e::indicator::request_attention();
1592 }
1593
1594 #[cfg(feature = "t1000e")]
1595 fn clear_attention(&mut self) {
1596 umsh_bsp_t1000e::indicator::clear_attention();
1597 }
1598
1599 #[cfg(feature = "cap-battery-saadc")]
1600 fn note_transmit_load(&mut self) {
1601 // Mark the load for the battery level estimator (the radio
1602 // runner transmits within milliseconds of this).
1603 board_power::note_external_load();
1604 }
1605
1606 fn set_alert(&mut self, state: umsh_ulcp::alert::AlertState) {
1607 set_alert_indication(state.is_active());
1608 }
1609
1610 #[cfg(all(feature = "cap-gnss", feature = "t1000e"))]
1611 fn gnss_switched(&mut self, enabled: bool) {
1612 // Two indications rather than one confirmation blink: the
1613 // gesture is a toggle, so "it worked" tells the operator
1614 // nothing they did not already know. Which way it went is
1615 // the only thing worth reporting, and the buzzer carries it
1616 // for a device still in a pocket.
1617 umsh_bsp_t1000e::indicator::LED_SEQUENCE_SIGNAL.signal(if enabled {
1618 LedSequence::GnssOn
1619 } else {
1620 LedSequence::GnssOff
1621 });
1622 umsh_bsp_t1000e::BUZZER_SIGNAL.signal(if enabled {
1623 &umsh_ux_tracker::buzzer::melodies::GNSS_ON
1624 } else {
1625 &umsh_ux_tracker::buzzer::melodies::GNSS_OFF
1626 });
1627 }
1628
1629 fn trace(&mut self, args: core::fmt::Arguments<'_>) {
1630 debug_log(args);
1631 }
1632 }
1633
1634 // ─── Tasks ───────────────────────────────────────────────────────────────
1635
1636 #[embassy_executor::task]
1637 async fn mpsl_task(mpsl: &'static MultiprotocolServiceLayer<'static>) -> ! {
1638 mpsl.run().await
1639 }
1640
1641 async fn ble_runner<C: Controller, P: PacketPool>(mut runner: Runner<'_, C, P>) -> ! {
1642 loop {
1643 match runner.run().await {
1644 Ok(()) => debug_log(format_args!("ble runner exited cleanly")),
1645 Err(error) => debug_log(format_args!("ble runner error={error:?}")),
1646 }
1647 }
1648 }
1649
1650 /// How long a pairing window stays open before it closes itself.
1651 ///
1652 /// Boards that can *ask* for a window — a menu entry, or a
1653 /// hold-through-power-on gesture — get 30 s, because reopening one is
1654 /// cheap. A `boot-pairing-window` board has neither, so its only
1655 /// window is the automatic one at boot and it is deliberately shorter:
1656 /// it is open on every single boot rather than on request, so the
1657 /// exposure is recurring and the length is the only thing limiting it.
1658 #[cfg(not(feature = "boot-pairing-window"))]
1659 const PAIRING_WINDOW_SECS: u64 = 30;
1660 #[cfg(feature = "boot-pairing-window")]
1661 const PAIRING_WINDOW_SECS: u64 = 20;
1662
1663 async fn pairing_timeout<C: Controller, P: PacketPool>(stack: &Stack<'_, C, P>) -> ! {
1664 loop {
1665 match select(
1666 Timer::after_secs(PAIRING_WINDOW_SECS),
1667 PAIRING_TIMER_RESET.wait(),
1668 )
1669 .await
1670 {
1671 Either::First(()) => {
1672 debug_log(format_args!("pairing window expired"));
1673 PAIRING_MODE.store(false, Ordering::Release);
1674 BLE_LED_MODE.store(0, Ordering::Release);
1675 UI_REFRESH.signal(());
1676 apply_pairing_gate(stack);
1677 }
1678 Either::Second(()) => debug_log(format_args!("pairing timer reset")),
1679 }
1680 }
1681 }
1682
1683 async fn pairing_config_task<C: Controller, P: PacketPool>(
1684 stack: &Stack<'_, C, P>,
1685 store: &BleStoreMutex,
1686 ) -> ! {
1687 loop {
1688 match select3(
1689 PAIRING_CONFIG_CH.receive(),
1690 PAIRING_MODE_REQUEST.wait(),
1691 BLE_WIPE_REQUEST.wait(),
1692 )
1693 .await
1694 {
1695 Either3::First(pin) => {
1696 debug_log(format_args!(
1697 "pin config begin configured={}",
1698 pin.is_some()
1699 ));
1700 let persisted = match store.lock().await.set_pin(pin).await {
1701 Ok(()) => {
1702 debug_log(format_args!("pin config persist=ok"));
1703 true
1704 }
1705 Err(()) => {
1706 debug_log(format_args!("pin config persist=FAILED"));
1707 false
1708 }
1709 };
1710 let applied = if persisted {
1711 match stack.set_fixed_passkey(pin) {
1712 Ok(()) => {
1713 debug_log(format_args!("pin config trouble-passkey=ok"));
1714 true
1715 }
1716 Err(error) => {
1717 debug_log(format_args!(
1718 "pin config trouble-passkey=FAILED error={error:?}"
1719 ));
1720 false
1721 }
1722 }
1723 } else {
1724 debug_log(format_args!("pin config trouble-passkey=skipped"));
1725 false
1726 };
1727 let result = persisted && applied;
1728 if result {
1729 stack.set_io_capabilities(if pin.is_some() {
1730 IoCapabilities::DisplayOnly
1731 } else {
1732 IoCapabilities::NoInputNoOutput
1733 });
1734 PAIRING_PIN.store(pin.unwrap_or(u32::MAX), Ordering::Release);
1735 apply_pairing_gate(stack);
1736 }
1737 debug_log(format_args!(
1738 "pin config requested={} persisted={} applied={}",
1739 pin.is_some(),
1740 persisted,
1741 result,
1742 ));
1743 PAIRING_CONFIG_ACK.signal(result);
1744 }
1745 Either3::Second(()) => {
1746 debug_log(format_args!("pairing mode requested"));
1747 PAIRING_MODE.store(true, Ordering::Release);
1748 BLE_LED_MODE.store(1, Ordering::Release);
1749 let unavailable = PAIRING_LOCKED_OUT.load(Ordering::Acquire)
1750 || usize::from(BLE_BOND_COUNT.load(Ordering::Acquire))
1751 >= ble_store::MAX_BONDS;
1752 UI_NOTICE.signal(if unavailable {
1753 UiNotice::PairingUnavailable
1754 } else {
1755 UiNotice::PairingStarted
1756 });
1757 PAIRING_TIMER_RESET.signal(());
1758 apply_pairing_gate(stack);
1759 }
1760 Either3::Third(()) => {
1761 debug_log(format_args!("security wipe requested"));
1762 if store.lock().await.clear_security().await.is_ok() {
1763 debug_log(format_args!("security wipe flash=ok"));
1764 BLE_BOND_COUNT.store(0, Ordering::Release);
1765 let mut identities: heapless::Vec<Identity, { ble_store::MAX_BONDS }> =
1766 heapless::Vec::new();
1767 stack.with_bond_information(|bonds| {
1768 for bond in bonds {
1769 if identities.push(bond.identity).is_err() {
1770 debug_log(format_args!("security wipe identity-list=FULL"));
1771 }
1772 }
1773 });
1774 debug_log(format_args!(
1775 "security wipe removing-bonds count={}",
1776 identities.len()
1777 ));
1778 for identity in identities {
1779 match stack.remove_bond_information(identity) {
1780 Ok(()) => debug_log(format_args!("security wipe remove-bond=ok")),
1781 Err(error) => debug_log(format_args!(
1782 "security wipe remove-bond=FAILED error={error:?}"
1783 )),
1784 }
1785 }
1786 match stack.set_fixed_passkey(None) {
1787 Ok(()) => debug_log(format_args!("security wipe clear-passkey=ok")),
1788 Err(error) => debug_log(format_args!(
1789 "security wipe clear-passkey=FAILED error={error:?}"
1790 )),
1791 }
1792 stack.set_io_capabilities(IoCapabilities::NoInputNoOutput);
1793 PAIRING_PIN.store(u32::MAX, Ordering::Release);
1794 PAIRING_FAILURES.store(0, Ordering::Release);
1795 PAIRING_LOCKED_OUT.store(false, Ordering::Release);
1796 PAIRING_MODE.store(true, Ordering::Release);
1797 BLE_LED_MODE.store(2, Ordering::Release);
1798 PAIRING_TIMER_RESET.signal(());
1799 apply_pairing_gate(stack);
1800 debug_log(format_args!("security wipe complete"));
1801 UI_NOTICE.signal(UiNotice::BondsCleared);
1802 } else {
1803 debug_log(format_args!("security wipe flash=FAILED"));
1804 UI_NOTICE.signal(UiNotice::ClearFailed);
1805 }
1806 }
1807 }
1808 }
1809 }
1810
1811 fn classify_pairing_failure(error: &trouble_host::Error) -> PairingFailureClass {
1812 match error {
1813 trouble_host::Error::Security(PairingFailedReason::ConfirmValueFailed) => {
1814 PairingFailureClass::ConfirmValue
1815 }
1816 trouble_host::Error::Security(PairingFailedReason::DHKeyCheckFailed) => {
1817 PairingFailureClass::DhKeyCheck
1818 }
1819 _ => PairingFailureClass::Other,
1820 }
1821 }
1822
1823 async fn advertise<'values, 'server, C: Controller>(
1824 peripheral: &mut Peripheral<'values, C, DefaultPacketPool>,
1825 server: &'server UlcpServer<'values>,
1826 ) -> Result<GattConnection<'values, 'server, DefaultPacketPool>, BleHostError<C::Error>> {
1827 const SERVICE_UUID_LE: [u8; 16] = gatt::SERVICE_UUID.to_le_bytes();
1828 // The advertisement and the GAP characteristic must agree, and this
1829 // is the one place both are known to be about to matter.
1830 sync_gap_device_name(server).await;
1831 let name = {
1832 let configured = DEVICE_NAME.lock().await;
1833 if configured.is_empty() {
1834 DeviceName::from_slice(default_device_name().as_bytes()).expect("default name fits")
1835 } else {
1836 configured.clone()
1837 }
1838 };
1839 let adv_name_len = utf8_prefix_len(name.as_slice(), 8);
1840 let mut adv_data = [0u8; 31];
1841 let adv_len = AdStructure::encode_slice(
1842 &[
1843 AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED),
1844 AdStructure::CompleteServiceUuids128(&[SERVICE_UUID_LE]),
1845 AdStructure::ShortenedLocalName(&name[..adv_name_len]),
1846 ],
1847 &mut adv_data,
1848 )?;
1849 let scan_name_len = utf8_prefix_len(name.as_slice(), 29);
1850 let scan_name = if scan_name_len == name.len() {
1851 AdStructure::CompleteLocalName(&name[..scan_name_len])
1852 } else {
1853 AdStructure::ShortenedLocalName(&name[..scan_name_len])
1854 };
1855 let mut scan_data = [0u8; 31];
1856 let scan_len = AdStructure::encode_slice(&[scan_name], &mut scan_data)?;
1857 debug_log(format_args!(
1858 "advertising start adv-bytes={} scan-bytes={}",
1859 adv_len, scan_len
1860 ));
1861 let advertiser = peripheral
1862 .advertise(
1863 &Default::default(),
1864 Advertisement::ConnectableScannableUndirected {
1865 adv_data: &adv_data[..adv_len],
1866 scan_data: &scan_data[..scan_len],
1867 },
1868 )
1869 .await?;
1870 debug_log(format_args!("advertising controller-active"));
1871 let raw_connection = advertiser.accept().await?;
1872 debug_log(format_args!("advertising raw-connection accepted"));
1873 let connection = raw_connection.with_attribute_server(server)?;
1874 debug_log(format_args!("advertising gatt-server attached"));
1875 Ok(connection)
1876 }
1877
1878 fn utf8_prefix_len(bytes: &[u8], maximum: usize) -> usize {
1879 let text = core::str::from_utf8(bytes).expect("validated device name");
1880 let mut len = bytes.len().min(maximum);
1881 while !text.is_char_boundary(len) {
1882 len -= 1;
1883 }
1884 len
1885 }
1886
1887 async fn send_ble_frame(
1888 server: &UlcpServer<'_>,
1889 conn: &GattConnection<'_, '_, DefaultPacketPool>,
1890 outbound: OutFrame,
1891 ) -> Result<(), trouble_host::Error> {
1892 if SESSION_GEN.load(Ordering::Acquire) != outbound.generation {
1893 debug_log(format_args!(
1894 "ble outbound dropped stale-generation frame-gen={} active-gen={}",
1895 outbound.generation,
1896 SESSION_GEN.load(Ordering::Acquire),
1897 ));
1898 return Ok(());
1899 }
1900 let segment_payload = usize::from(conn.raw().att_mtu())
1901 .saturating_sub(4)
1902 .clamp(1, BLE_VALUE_MAX - 1);
1903 let mut segments = generation_checked(
1904 gatt::segments(&outbound.frame, segment_payload),
1905 outbound.generation,
1906 || SESSION_GEN.load(Ordering::Acquire),
1907 );
1908 for segment in segments.by_ref() {
1909 let mut value: heapless09::Vec<u8, BLE_VALUE_MAX> = heapless09::Vec::new();
1910 value
1911 .push(segment.header())
1912 .map_err(|_| trouble_host::Error::InsufficientSpace)?;
1913 value
1914 .extend_from_slice(segment.payload())
1915 .map_err(|_| trouble_host::Error::InsufficientSpace)?;
1916 server.ulcp.frame_out.notify(conn, &value, false).await?;
1917 }
1918 if segments.stale() {
1919 debug_log(format_args!(
1920 "ble outbound segmentation stopped generation-changed"
1921 ));
1922 }
1923 Ok(())
1924 }
1925
1926 /// A link-level signal the connection loop reacts to, other than a GATT
1927 /// event or an outbound frame.
1928 enum LinkSignal {
1929 AdvertisingPolicy,
1930 DeviceName,
1931 }
1932
1933 /// The GAP Device Name value for `name`, truncated on a UTF-8 boundary.
1934 ///
1935 /// The characteristic is inline-stored and therefore shorter than the
1936 /// ULCP name limit; GAP gets a prefix rather than the full name, exactly
1937 /// as the advertisement does.
1938 fn gap_device_name(name: &[u8]) -> GapDeviceName {
1939 let len = utf8_prefix_len(name, gap::DEVICE_NAME_MAX_LENGTH);
1940 GapDeviceName::from_slice(&name[..len]).unwrap_or_default()
1941 }
1942
1943 /// Publish the configured name on the GAP Device Name characteristic.
1944 ///
1945 /// Called before advertising and after a rename, so the value a client
1946 /// reads is the current name rather than whatever the device booted
1947 /// with.
1948 async fn sync_gap_device_name(server: &UlcpServer<'_>) {
1949 let Some(gap) = server.gap.as_ref() else {
1950 return;
1951 };
1952 let name = device_name_snapshot().await;
1953 if server
1954 .set(&gap.device_name, &gap_device_name(name.as_slice()))
1955 .is_err()
1956 {
1957 debug_log(format_args!("gap device-name update FAILED"));
1958 }
1959 }
1960
1961 /// Tell a connected client that its cached attributes are stale.
1962 ///
1963 /// A bonded iOS client caches the GAP device name against the bond and
1964 /// will keep showing the old one — in Settings › Bluetooth and to every
1965 /// app on the phone — until a Service Changed indication makes it
1966 /// re-read. The indicated range covers the whole table, because the
1967 /// point is to invalidate a cache rather than to describe a structural
1968 /// change. Clients that never subscribed are skipped inside trouble.
1969 async fn announce_gatt_change(
1970 server: &UlcpServer<'_>,
1971 conn: &GattConnection<'_, '_, DefaultPacketPool>,
1972 ) {
1973 let Some(gap) = server.gap.as_ref() else {
1974 return;
1975 };
1976 // A client that has not subscribed cannot be told anything, and
1977 // `indicate` reports that case as success. Checking first keeps the
1978 // stale marker set so the next connection tries again.
1979 if !gap.service_changed.should_indicate(conn) {
1980 debug_log(format_args!("service-changed not subscribed; deferring"));
1981 return;
1982 }
1983 const WHOLE_TABLE: [u8; 4] = [0x01, 0x00, 0xFF, 0xFF];
1984 match gap
1985 .service_changed
1986 .indicate(conn, &WHOLE_TABLE, false)
1987 .await
1988 {
1989 Ok(()) => {
1990 GATT_NAME_STALE.store(false, Ordering::Release);
1991 debug_log(format_args!("service-changed indicated"));
1992 }
1993 Err(error) => debug_log(format_args!("service-changed indicate error={error:?}")),
1994 }
1995 }
1996
1997 async fn gatt_connection<C: Controller, P: PacketPool>(
1998 stack: &Stack<'_, C, P>,
1999 store: &BleStoreMutex,
2000 server: &UlcpServer<'_>,
2001 conn: &GattConnection<'_, '_, DefaultPacketPool>,
2002 ) -> Result<(), trouble_host::Error> {
2003 match conn.raw().set_bondable(true) {
2004 Ok(()) => debug_log(format_args!("connection set-bondable=ok")),
2005 Err(error) => {
2006 debug_log(format_args!(
2007 "connection set-bondable=FAILED error={error:?}"
2008 ));
2009 return Err(error);
2010 }
2011 }
2012 let peer = conn.raw().peer_identity();
2013 debug_log(format_args!(
2014 "connected peer={} kind={} irk={} table_match={} level={:?} mtu={}",
2015 peer.addr,
2016 peer.addr.to_bytes()[0],
2017 peer.irk.is_some(),
2018 conn.raw().is_bonded_peer(),
2019 conn.raw().security_level(),
2020 conn.raw().att_mtu(),
2021 ));
2022 let mut attached = false;
2023 BLE_LINK.store(BLE_LINK_CONNECTED, Ordering::Release);
2024 let mut reassembler: gatt::Reassembler<{ gatt::MAX_FRAME }> = gatt::Reassembler::new();
2025
2026 // Reap a connection that never reaches encryption, so an unbonded or
2027 // unresolvable central — e.g. an iOS OS-level background reconnect that
2028 // presents an RPA we can't resolve — cannot squat the single peripheral
2029 // slot at NoEncryption and lock out the real client. A bonded reconnect
2030 // encrypts in ~0.3 s (see connect→Encrypted in the trace); 5 s leaves
2031 // ~2x headroom for a slow negotiated connection interval. A deliberate
2032 // pairing (the user pressed pair, then enters the PIN on the phone) gets
2033 // the full pairing-window grace instead.
2034 let grace = if PAIRING_MODE.load(Ordering::Acquire) {
2035 Duration::from_secs(40)
2036 } else {
2037 Duration::from_secs(5)
2038 };
2039 let mut encrypted = false;
2040 let mut deadline = core::pin::pin!(Timer::after(grace));
2041
2042 loop {
2043 let event = {
2044 let grace_guard = async {
2045 if encrypted {
2046 core::future::pending::<()>().await
2047 } else {
2048 deadline.as_mut().await
2049 }
2050 };
2051 // The two link-level signals share one arm so the GATT event
2052 // match below keeps its shape.
2053 let link_signal = async {
2054 match select(ADV_POLICY_CHANGED.wait(), DEVICE_NAME_CHANGED.wait()).await {
2055 Either::First(()) => LinkSignal::AdvertisingPolicy,
2056 Either::Second(()) => LinkSignal::DeviceName,
2057 }
2058 };
2059 match select(
2060 select3(conn.next(), OUT_CH.ble.receive(), link_signal),
2061 grace_guard,
2062 )
2063 .await
2064 {
2065 Either::First(event) => event,
2066 Either::Second(()) => {
2067 debug_log(format_args!(
2068 "unencrypted connection grace expired; disconnecting squatter"
2069 ));
2070 conn.raw().disconnect();
2071 break;
2072 }
2073 }
2074 };
2075 match event {
2076 Either3::First(GattConnectionEvent::Disconnected { reason }) => {
2077 debug_log(format_args!("disconnected reason={reason:?}"));
2078 break;
2079 }
2080 Either3::First(GattConnectionEvent::PairingComplete { bond, .. }) => {
2081 debug_log(format_args!(
2082 "pairing-complete bond={} table_match={}",
2083 bond.is_some(),
2084 conn.raw().is_bonded_peer(),
2085 ));
2086 if let Some(bond) = bond {
2087 if !bond_identity_is_persistable(&bond) {
2088 debug_log(format_args!("pairing bond identity=incomplete"));
2089 match stack.remove_bond_information(bond.identity) {
2090 Ok(()) => {
2091 debug_log(format_args!("pairing incomplete bond remove=ok"))
2092 }
2093 Err(error) => debug_log(format_args!(
2094 "pairing incomplete bond remove=FAILED error={error:?}"
2095 )),
2096 }
2097 debug_log(format_args!(
2098 "disconnect initiated by incomplete pairing identity"
2099 ));
2100 conn.raw().disconnect();
2101 break;
2102 }
2103 let persisted_bonds = match persist_bond(store, &bond).await {
2104 Ok((count, evicted)) => {
2105 forget_evicted_bond(stack, evicted);
2106 count
2107 }
2108 Err(()) => {
2109 debug_log(format_args!("pairing bond persist=FAILED"));
2110 match stack.remove_bond_information(bond.identity) {
2111 Ok(()) => debug_log(format_args!(
2112 "pairing unpersisted bond remove=ok"
2113 )),
2114 Err(error) => debug_log(format_args!(
2115 "pairing unpersisted bond remove=FAILED error={error:?}"
2116 )),
2117 }
2118 debug_log(format_args!(
2119 "disconnect initiated by pairing persistence failure"
2120 ));
2121 conn.raw().disconnect();
2122 break;
2123 }
2124 };
2125 BLE_BOND_COUNT.store(persisted_bonds as u8, Ordering::Release);
2126 UI_REFRESH.signal(());
2127 debug_log(format_args!(
2128 "pairing bond persist=ok peer={} kind={} irk={} bonded={} level={:?}",
2129 bond.identity.addr,
2130 bond.identity.addr.to_bytes()[0],
2131 bond.identity.irk.is_some(),
2132 bond.is_bonded,
2133 bond.security_level,
2134 ));
2135 }
2136 // Trouble may report a successful peripheral pairing with
2137 // bond=None and expose the completed bond at the first
2138 // protected GATT edge. Pairing success still resets the
2139 // failure counter and closes the window in that case.
2140 publish_pairing_runtime(pairing_runtime().pairing_succeeded());
2141 BLE_LED_MODE.store(0, Ordering::Release);
2142 apply_pairing_gate(stack);
2143 }
2144 Either3::First(GattConnectionEvent::PassKeyDisplay(_)) => {
2145 debug_log(format_args!("passkey display requested"));
2146 }
2147 Either3::First(GattConnectionEvent::PassKeyConfirm(_)) => {
2148 debug_log(format_args!("passkey confirmation requested"));
2149 }
2150 Either3::First(GattConnectionEvent::PassKeyInput) => {
2151 debug_log(format_args!("passkey input requested"));
2152 }
2153 Either3::First(GattConnectionEvent::BondLost) => {
2154 debug_log(format_args!("bond lost event"));
2155 }
2156 Either3::First(GattConnectionEvent::OobRequest) => {
2157 debug_log(format_args!("oob requested"));
2158 }
2159 Either3::First(GattConnectionEvent::Encrypted { bond, .. }) => {
2160 debug_log(format_args!(
2161 "encrypted event_bond={} table_match={} level={:?}",
2162 bond.is_some(),
2163 conn.raw().is_bonded_peer(),
2164 conn.raw().security_level(),
2165 ));
2166 // Link is encrypted: this is not a squatter, so stop the
2167 // unencrypted-connection reaper regardless of bond state.
2168 encrypted = true;
2169 if bond.is_some() || conn.raw().is_bonded_peer() {
2170 let peer = conn.raw().peer_identity();
2171 let raw = peer.addr.to_bytes();
2172 let address: [u8; 6] = raw[1..].try_into().unwrap();
2173 match store.lock().await.touch_bond(raw[0], address).await {
2174 Ok(true) => debug_log(format_args!("bond lru touch=moved")),
2175 Ok(false) => {}
2176 Err(()) => debug_log(format_args!("bond lru touch=FAILED")),
2177 }
2178 publish_pairing_runtime(pairing_runtime().bonded_reconnect());
2179 BLE_LED_MODE.store(0, Ordering::Release);
2180 apply_pairing_gate(stack);
2181 }
2182 // A bonded client caches attributes across connections,
2183 // so a rename it missed has to be announced now. Only a
2184 // client that has subscribed to Service Changed — which
2185 // it does after encrypting — can be told.
2186 if GATT_NAME_STALE.load(Ordering::Acquire) {
2187 announce_gatt_change(server, conn).await;
2188 }
2189 }
2190 Either3::First(GattConnectionEvent::PairingFailed(error)) => {
2191 debug_log(format_args!("pairing-failed error={error:?}"));
2192 let failure = classify_pairing_failure(&error);
2193 if failure.counts_toward_lockout() {
2194 let before = pairing_runtime();
2195 let after = before.record_failure(failure);
2196 publish_pairing_runtime(after);
2197 debug_log(format_args!(
2198 "pairing authentication-failures={} locked={}",
2199 after.failures, after.locked_out,
2200 ));
2201 if after.locked_out && !before.locked_out {
2202 apply_pairing_gate(stack);
2203 }
2204 }
2205 }
2206 Either3::First(GattConnectionEvent::Gatt { event }) => {
2207 match &event {
2208 GattEvent::Read(read) => debug_log(format_args!(
2209 "gatt read handle=0x{:04x} level={:?}",
2210 read.handle(),
2211 conn.raw().security_level(),
2212 )),
2213 GattEvent::Write(write) => debug_log(format_args!(
2214 "gatt write handle=0x{:04x} level={:?}",
2215 write.handle(),
2216 conn.raw().security_level(),
2217 )),
2218 GattEvent::NotAllowed(event) => debug_log(format_args!(
2219 "gatt not-allowed handle=0x{:04x} level={:?}",
2220 event.handle(),
2221 conn.raw().security_level(),
2222 )),
2223 GattEvent::Other(event) => debug_log(format_args!(
2224 "gatt other handle={:?} level={:?}",
2225 event.payload().handle(),
2226 conn.raw().security_level(),
2227 )),
2228 }
2229 let frame_in = matches!(&event, GattEvent::Write(write) if write.handle() == server.ulcp.frame_in.handle);
2230 let cccd = matches!(&event, GattEvent::Write(write) if Some(write.handle()) == server.ulcp.frame_out.cccd_handle);
2231 let protected = frame_in || cccd;
2232 let bonded = conn.raw().is_bonded_peer();
2233 let mut bond_persist_failed = false;
2234 // PairingComplete is not guaranteed to carry the newly-created bond on
2235 // every peripheral path. The protected GATT edge is authoritative: if
2236 // Trouble says this peer is bonded, find that exact live-table entry and
2237 // make it durable before granting access. add_bond is idempotent, so
2238 // subsequent frames do not write flash.
2239 let durable_bond = if protected && bonded {
2240 let peer = conn.raw().peer_identity();
2241 let bond = stack.with_bond_information(|bonds| {
2242 bonds
2243 .iter()
2244 .find(|bond| bond.identity.match_identity(&peer))
2245 .cloned()
2246 });
2247 match bond {
2248 Some(bond) if !bond_identity_is_persistable(&bond) => {
2249 debug_log(format_args!("protected bond identity=pending"));
2250 false
2251 }
2252 Some(bond) => match persist_bond(store, &bond).await {
2253 Ok((count, evicted)) => {
2254 debug_log(format_args!("protected bond persist=ok"));
2255 forget_evicted_bond(stack, evicted);
2256 BLE_BOND_COUNT.store(count as u8, Ordering::Release);
2257 UI_REFRESH.signal(());
2258 apply_pairing_gate(stack);
2259 true
2260 }
2261 Err(()) => {
2262 debug_log(format_args!("protected bond persist=FAILED"));
2263 bond_persist_failed = true;
2264 match stack.remove_bond_information(bond.identity) {
2265 Ok(()) => debug_log(format_args!(
2266 "protected unpersisted bond remove=ok"
2267 )),
2268 Err(error) => debug_log(format_args!(
2269 "protected unpersisted bond remove=FAILED error={error:?}"
2270 )),
2271 }
2272 false
2273 }
2274 },
2275 None => {
2276 debug_log(format_args!("protected bond lookup=missing"));
2277 false
2278 }
2279 }
2280 } else {
2281 !protected
2282 };
2283 if protected {
2284 let peer = conn.raw().peer_identity();
2285 debug_log(format_args!(
2286 "gatt protected={} bonded={} durable={} peer={} kind={} irk={} level={:?}",
2287 if cccd { "cccd" } else { "frame-in" },
2288 bonded,
2289 durable_bond,
2290 peer.addr,
2291 peer.addr.to_bytes()[0],
2292 peer.irk.is_some(),
2293 conn.raw().security_level(),
2294 ));
2295 }
2296 let mut inbound: heapless09::Vec<u8, BLE_VALUE_MAX> = heapless09::Vec::new();
2297 if frame_in {
2298 if let GattEvent::Write(write) = &event {
2299 write.with_data(|_, data| {
2300 if inbound.extend_from_slice(data).is_err() {
2301 debug_log(format_args!(
2302 "gatt frame-in staging=FAILED len={}",
2303 data.len()
2304 ));
2305 }
2306 });
2307 }
2308 }
2309
2310 let server_permission_denied = matches!(&event, GattEvent::NotAllowed(_));
2311 let reply = if protected && !(bonded && durable_bond) {
2312 debug_log(format_args!(
2313 "gatt decision=reject insufficient-authentication"
2314 ));
2315 event.reject(AttErrorCode::INSUFFICIENT_AUTHENTICATION)
2316 } else if server_permission_denied {
2317 // `NotAllowedEvent::accept()` preserves and returns the
2318 // attribute server's permission error; it does not grant
2319 // the operation. Make that non-obvious Trouble API
2320 // behavior explicit in the hardware trace.
2321 debug_log(format_args!("gatt decision=return-server-permission-error"));
2322 event.accept()
2323 } else {
2324 debug_log(format_args!("gatt decision=accept"));
2325 event.accept()
2326 }?;
2327 reply.send().await;
2328
2329 if protected && bonded && !durable_bond && bond_persist_failed {
2330 debug_log(format_args!(
2331 "disconnect initiated by protected bond persistence failure"
2332 ));
2333 conn.raw().disconnect();
2334 break;
2335 }
2336
2337 if frame_in && bonded {
2338 match reassembler.push(&inbound) {
2339 Some(Ok(frame)) => {
2340 let mut value: FrameBuf = heapless::Vec::new();
2341 match value.extend_from_slice(frame) {
2342 Ok(()) => {
2343 debug_log(format_args!(
2344 "gatt frame-in complete len={}",
2345 frame.len()
2346 ));
2347 INPUT_CH.send(InEvent::Frame(Transport::Ble, value)).await;
2348 }
2349 Err(()) => debug_log(format_args!(
2350 "gatt frame-in complete staging=FAILED len={}",
2351 frame.len()
2352 )),
2353 }
2354 }
2355 Some(Err(error)) => debug_log(format_args!(
2356 "gatt frame-in decode=FAILED error={error:?} segment-len={}",
2357 inbound.len()
2358 )),
2359 None => debug_log(format_args!(
2360 "gatt frame-in segment accepted segment-len={} complete=false",
2361 inbound.len()
2362 )),
2363 }
2364 }
2365 if cccd && bonded {
2366 let subscribed = server.ulcp.frame_out.should_notify(conn);
2367 match (attached, subscribed) {
2368 (false, true) => {
2369 debug_log(format_args!("cccd subscribed=true"));
2370 attached = true;
2371 BLE_LINK.store(BLE_LINK_ATTACHED, Ordering::Release);
2372 UI_REFRESH.signal(());
2373 INPUT_CH.send(InEvent::Attached(Transport::Ble)).await;
2374 }
2375 (true, false) => {
2376 debug_log(format_args!("cccd subscribed=false"));
2377 attached = false;
2378 BLE_LINK.store(BLE_LINK_CONNECTED, Ordering::Release);
2379 UI_REFRESH.signal(());
2380 reassembler.reset();
2381 INPUT_CH.send(InEvent::Detached(Transport::Ble)).await;
2382 }
2383 (false, false) => debug_log(format_args!(
2384 "cccd state unchanged attached=false subscribed=false"
2385 )),
2386 (true, true) => debug_log(format_args!(
2387 "cccd state unchanged attached=true subscribed=true"
2388 )),
2389 }
2390 }
2391 }
2392 Either3::First(GattConnectionEvent::PhyUpdated { tx_phy, rx_phy }) => {
2393 debug_log(format_args!(
2394 "connection phy-updated tx={tx_phy:?} rx={rx_phy:?}"
2395 ));
2396 }
2397 Either3::First(GattConnectionEvent::ConnectionParamsUpdated {
2398 conn_interval,
2399 peripheral_latency,
2400 supervision_timeout,
2401 }) => {
2402 debug_log(format_args!(
2403 "connection params-updated interval-us={} latency={} timeout-us={}",
2404 conn_interval.as_micros(),
2405 peripheral_latency,
2406 supervision_timeout.as_micros(),
2407 ));
2408 }
2409 Either3::First(GattConnectionEvent::RequestConnectionParams(request)) => {
2410 debug_log(format_args!(
2411 "connection params-requested params={:?}",
2412 request.params()
2413 ));
2414 match request.accept(None, stack).await {
2415 Ok(()) => debug_log(format_args!("connection params-response=accepted")),
2416 Err(error) => debug_log(format_args!(
2417 "connection params-response=FAILED error={error:?}"
2418 )),
2419 }
2420 }
2421 Either3::First(GattConnectionEvent::DataLengthUpdated {
2422 max_tx_octets,
2423 max_tx_time,
2424 max_rx_octets,
2425 max_rx_time,
2426 }) => debug_log(format_args!(
2427 "connection data-length tx-octets={} tx-time={} rx-octets={} rx-time={}",
2428 max_tx_octets, max_tx_time, max_rx_octets, max_rx_time,
2429 )),
2430 Either3::First(GattConnectionEvent::FrameSpaceUpdated {
2431 frame_space,
2432 initiator,
2433 phys,
2434 spacing_types,
2435 }) => debug_log(format_args!(
2436 "connection frame-space us={} initiator={initiator:?} phys={phys:?} spacing={spacing_types:?}",
2437 frame_space.as_micros(),
2438 )),
2439 Either3::First(GattConnectionEvent::ConnectionRateChanged {
2440 conn_interval,
2441 subrate_factor,
2442 peripheral_latency,
2443 continuation_number,
2444 supervision_timeout,
2445 }) => debug_log(format_args!(
2446 "connection rate-changed interval-us={} subrate={} latency={} continuation={} timeout-us={}",
2447 conn_interval.as_micros(),
2448 subrate_factor,
2449 peripheral_latency,
2450 continuation_number,
2451 supervision_timeout.as_micros(),
2452 )),
2453 Either3::Second(outbound) => {
2454 if attached && conn.raw().is_bonded_peer() {
2455 send_ble_frame(server, conn, outbound).await?;
2456 } else {
2457 debug_log(format_args!(
2458 "ble outbound dropped attached={} bonded={} level={:?}",
2459 attached,
2460 conn.raw().is_bonded_peer(),
2461 conn.raw().security_level(),
2462 ));
2463 }
2464 }
2465 Either3::Third(LinkSignal::AdvertisingPolicy) => {
2466 if !ADV_ALLOWED.load(Ordering::Acquire) {
2467 debug_log(format_args!(
2468 "disconnect initiated by transport arbitration"
2469 ));
2470 conn.raw().disconnect();
2471 break;
2472 }
2473 }
2474 Either3::Third(LinkSignal::DeviceName) => {
2475 sync_gap_device_name(server).await;
2476 announce_gatt_change(server, conn).await;
2477 }
2478 }
2479 }
2480 BLE_LINK.store(BLE_LINK_NONE, Ordering::Release);
2481 UI_REFRESH.signal(());
2482 if attached {
2483 INPUT_CH.send(InEvent::Detached(Transport::Ble)).await;
2484 }
2485 Ok(())
2486 }
2487
2488 async fn ble_peripheral<'values, C: Controller>(
2489 stack: &Stack<'_, C, DefaultPacketPool>,
2490 store: &BleStoreMutex,
2491 peripheral: &mut Peripheral<'values, C, DefaultPacketPool>,
2492 server: &UlcpServer<'values>,
2493 ) -> ! {
2494 loop {
2495 if !ADV_ALLOWED.load(Ordering::Acquire) {
2496 ADV_POLICY_CHANGED.wait().await;
2497 continue;
2498 }
2499 super::panic::breadcrumb_mark(12);
2500 match select3(
2501 advertise(peripheral, server),
2502 ADV_POLICY_CHANGED.wait(),
2503 DEVICE_NAME_CHANGED.wait(),
2504 )
2505 .await
2506 {
2507 Either3::First(Ok(connection)) => {
2508 match gatt_connection(stack, store, server, &connection).await {
2509 Ok(()) => debug_log(format_args!("gatt connection task ended ok")),
2510 Err(error) => {
2511 debug_log(format_args!("gatt connection task error={error:?}"))
2512 }
2513 }
2514 }
2515 Either3::First(Err(error)) => {
2516 debug_log(format_args!("advertising error={error:?}"))
2517 }
2518 Either3::Second(()) => debug_log(format_args!("advertising policy changed")),
2519 Either3::Third(()) => debug_log(format_args!("advertising device name changed")),
2520 }
2521 }
2522 }
2523
2524 /// Startup-failure containment: a misconfigured or failed BLE bring-up
2525 /// must degrade to a USB-only device, never a panic/reboot loop — a
2526 /// display-less field node that boot-loops is unrecoverable in place.
2527 /// Parks the BLE app forever; USB keeps running via the outer join.
2528 async fn ble_disabled_park(reason: &'static str) -> ! {
2529 loop {
2530 debug_log(format_args!("BLE DISABLED: {reason}"));
2531 Timer::after_secs(600).await;
2532 }
2533 }
2534
2535 async fn ble_app<C: Controller>(controller: C, store: BleStore) -> ! {
2536 // Install the log→serial bridge before the host stack starts so
2537 // trouble-host's boot-time resolving-list diagnostics are captured.
2538 #[cfg(feature = "ble-debug")]
2539 init_foreign_crate_logging();
2540 super::panic::breadcrumb_mark(10);
2541 let mut resources: HostResources<
2542 _,
2543 DefaultPacketPool,
2544 BLE_CONNECTIONS_MAX,
2545 BLE_L2CAP_CHANNELS_MAX,
2546 > = HostResources::new();
2547 let initial = store.snapshot().clone();
2548 debug_log(format_args!(
2549 "ble boot identity={} bonds={} pin={} local_irk={} privacy=false",
2550 ble_identity_address(),
2551 initial.bonds.len(),
2552 initial.pin.is_some(),
2553 initial.local_irk.is_some(),
2554 ));
2555 for bond in &initial.bonds {
2556 debug_log(format_args!(
2557 "restored bond peer-kind={} peer={:02x?} irk={} bonded={} level={}",
2558 bond.address_kind,
2559 bond.address,
2560 bond.irk.is_some(),
2561 bond.is_bonded,
2562 bond.security_level,
2563 ));
2564 }
2565 PAIRING_PIN.store(initial.pin.unwrap_or(u32::MAX), Ordering::Release);
2566 BLE_BOND_COUNT.store(initial.bonds.len() as u8, Ordering::Release);
2567 // `boot-pairing-window` boards open a window on *every* boot,
2568 // bonded or not. They have no button and no menu, so this is the
2569 // only way to ever pair a second host — without it the first
2570 // bond would lock everyone else out permanently. Pressing RESET
2571 // is the physical-presence ceremony on those boards, standing in
2572 // for the button hold the others use; a configured PIN still
2573 // gates the pairing itself, and the failure lockout still applies.
2574 let initial_pairing_mode = initial.bonds.is_empty()
2575 || FORCE_PAIRING_AT_BOOT.load(Ordering::Acquire)
2576 || cfg!(feature = "boot-pairing-window");
2577 PAIRING_MODE.store(initial_pairing_mode, Ordering::Release);
2578 BLE_LED_MODE.store(u8::from(initial_pairing_mode), Ordering::Release);
2579 UI_REFRESH.signal(());
2580 let io_capabilities = if initial.pin.is_some() {
2581 IoCapabilities::DisplayOnly
2582 } else {
2583 IoCapabilities::NoInputNoOutput
2584 };
2585 let initial_pairing_enabled =
2586 pairing_enabled(initial_pairing_mode, initial.pin.is_some(), false);
2587 debug_log(format_args!(
2588 "ble stack configure io={io_capabilities:?} pairing-enabled={} fixed-passkey={}",
2589 initial_pairing_enabled,
2590 initial.pin.is_some(),
2591 ));
2592 let stack_builder = trouble_host::new(controller, &mut resources)
2593 .set_random_address(ble_identity_address())
2594 .set_io_capabilities(io_capabilities)
2595 .set_pairing_enabled(initial_pairing_enabled)
2596 .set_fixed_passkey(initial.pin);
2597 let stack = match stack_builder {
2598 Ok(builder) => {
2599 debug_log(format_args!("ble stack fixed-passkey configure=ok"));
2600 builder.build()
2601 }
2602 Err(error) => {
2603 debug_log(format_args!(
2604 "ble stack fixed-passkey configure=FAILED error={error:?}"
2605 ));
2606 ble_disabled_park("invalid fixed passkey").await
2607 }
2608 };
2609 for (index, bond) in initial.bonds.iter().enumerate() {
2610 if let Some(bond) = trouble_bond(bond) {
2611 match stack.add_bond_information(bond) {
2612 Ok(()) => debug_log(format_args!("restored bond index={index} add=ok")),
2613 Err(error) => debug_log(format_args!(
2614 "restored bond index={index} add=FAILED error={error:?}"
2615 )),
2616 }
2617 } else {
2618 debug_log(format_args!("restored bond index={index} decode=FAILED"));
2619 }
2620 }
2621 let store = BleStoreMutex::new(store);
2622 let runner = stack.runner();
2623 let mut peripheral = stack.peripheral();
2624 let server_result = UlcpServer::new_with_config(GapConfig::Peripheral(PeripheralConfig {
2625 name: default_device_name(),
2626 appearance: &appearance::computer::GENERIC_COMPUTER,
2627 }));
2628 let server = match server_result {
2629 Ok(server) => {
2630 debug_log(format_args!("gatt server construction=ok"));
2631 server
2632 }
2633 Err(error) => {
2634 debug_log(format_args!(
2635 "gatt server construction=FAILED error={error:?}"
2636 ));
2637 ble_disabled_park("gatt server construction failed").await
2638 }
2639 };
2640
2641 super::panic::breadcrumb_mark(11);
2642 join(
2643 ble_runner(runner),
2644 join(
2645 pairing_timeout(&stack),
2646 join(
2647 pairing_config_task(&stack, &store),
2648 ble_peripheral(&stack, &store, &mut peripheral, &server),
2649 ),
2650 ),
2651 )
2652 .await;
2653 unreachable!()
2654 }
2655
2656 /// Owns the `lora_phy::LoRa` instance via the reconfigurable device
2657 /// runner. RX preamble 8 symbols, TX preamble 16 (MeshCore parity).
2658 #[embassy_executor::task]
2659 async fn radio_task(lora: LoraRadio) {
2660 #[cfg(not(feature = "t1000e"))]
2661 const RX_PREAMBLE: u16 = 8;
2662 // Hardware bring-up established that the LR1110 misses MeshCore-US
2663 // traffic with an 8-symbol detector even though the SX1262 does not.
2664 #[cfg(feature = "t1000e")]
2665 const RX_PREAMBLE: u16 = 16;
2666 umsh_radio_loraphy::device_runner(lora, &RADIO_CH, &DEVICE_CTL, RX_PREAMBLE, 16).await;
2667 }
2668
2669 /// Owns the real `RADIO_CH` bundle and multiplexes it across the
2670 /// virtual per-client bundles (see `radio_mux`): per-client TX
2671 /// completion routing plus RX fan-out to every client.
2672 #[embassy_executor::task]
2673 async fn radio_mux_task() {
2674 super::radio_mux::radio_mux(&RADIO_CH, &MUX_CLIENTS).await
2675 }
2676
2677 /// Owns the USB `Sender`, HDLC-encodes frames, and writes USB packets.
2678 #[embassy_executor::task]
2679 async fn output_task(
2680 mut tx: DeviceSender,
2681 wdt_report: Option<&'static str>,
2682 panic_report: Option<&'static str>,
2683 ) {
2684 // Emit the previous boot's diagnostics (watchdog capture and/or
2685 // panic message) as ASCII to the first USB reader. HDLC hosts
2686 // resynchronize past it; humans read it with a serial terminal.
2687 // Wait for DTR — the OS CDC driver drains the IN endpoint even
2688 // with no process attached, so writing before a real opener
2689 // exists would discard the report into the void.
2690 if wdt_report.is_some() || panic_report.is_some() {
2691 while !tx.dtr() {
2692 Timer::after_millis(50).await;
2693 }
2694 Timer::after_millis(300).await;
2695 for report in [wdt_report, panic_report].into_iter().flatten() {
2696 for chunk in report.as_bytes().chunks(64) {
2697 let _ = tx.write_packet(chunk).await;
2698 }
2699 }
2700 }
2701 loop {
2702 #[cfg(feature = "ble-debug")]
2703 let outbound = match select(OUT_CH.wired.receive(), DEBUG_CH.receive()).await {
2704 Either::First(outbound) => outbound,
2705 Either::Second(line) => {
2706 for chunk in line.as_bytes().chunks(64) {
2707 let _ = tx.write_packet(chunk).await;
2708 }
2709 continue;
2710 }
2711 };
2712 #[cfg(not(feature = "ble-debug"))]
2713 let outbound = OUT_CH.wired.receive().await;
2714 if SESSION_GEN.load(Ordering::Acquire) != outbound.generation {
2715 continue;
2716 }
2717 let mut wire = [0u8; WIRE_MAX];
2718 let Ok(len) = hdlc::encode_frame(&outbound.frame, &mut wire) else {
2719 continue;
2720 };
2721 for chunk in generation_checked(wire[..len].chunks(64), outbound.generation, || {
2722 SESSION_GEN.load(Ordering::Acquire)
2723 }) {
2724 let _ = tx.write_packet(chunk).await;
2725 }
2726 }
2727 }
2728
2729 /// Owns the CDC receive half and HDLC decoder. Forwards frames and edges
2730 /// into INPUT_CH; `wait_connection` always precedes the read loop so
2731 /// a disconnected port never busy-loops.
2732 #[embassy_executor::task]
2733 async fn usb_in_task(mut rx: DeviceRescue) {
2734 loop {
2735 rx.wait_connection().await;
2736 debug_log(format_args!("usb debug attached"));
2737 let mut decoder: hdlc::Decoder<FRAME_IN_MAX> = hdlc::Decoder::new();
2738 let mut local_generation = SESSION_GEN.load(Ordering::Acquire);
2739 INPUT_CH.send(InEvent::Attached(Transport::Usb)).await;
2740 loop {
2741 let generation = SESSION_GEN.load(Ordering::Acquire);
2742 if generation != local_generation {
2743 decoder.reset();
2744 local_generation = generation;
2745 }
2746 let mut packet = [0u8; 64];
2747 match rx.read_packet(&mut packet).await {
2748 Ok(0) | Err(_) => break,
2749 Ok(len) => {
2750 for &byte in &packet[..len] {
2751 let Some(Ok(bytes)) = decoder.push(byte) else {
2752 continue;
2753 };
2754 let mut frame = heapless::Vec::new();
2755 let _ = frame.extend_from_slice(bytes);
2756 INPUT_CH.send(InEvent::Frame(Transport::Usb, frame)).await;
2757 }
2758 }
2759 }
2760 }
2761 INPUT_CH.send(InEvent::Detached(Transport::Usb)).await;
2762 }
2763 }
2764
2765 /// Owns the framing-free protocol session: hosts the shared ULCP
2766 /// driver (`umsh_ulcp_runtime::driver::run`) — host frames,
2767 /// radio receptions, transmit completions, and every session effect
2768 /// — over this board's channel wiring and [`BoardDeviceEnv`] couplings.
2769 #[embassy_executor::task]
2770 async fn device_task(
2771 boot_reason: Status,
2772 proto_store: ProtoStore,
2773 boot_snapshot: Option<BootSnapshot>,
2774 identity_store: ProtoStore,
2775 boot_identity: Option<[u8; 32]>,
2776 identity_rng: IdentityRng,
2777 node_counters: &'static NodeCountersMutex,
2778 ) {
2779 // The retained hardware reset cause answers the first
2780 // PROP_LAST_STATUS query; attach itself never modifies it.
2781 let session = Session::new(
2782 session_config(),
2783 boot_reason,
2784 CryptoEngine::new(SoftwareAes, SoftwareSha256),
2785 );
2786 driver::run(
2787 session,
2788 boot_snapshot.as_deref(),
2789 boot_identity,
2790 DeviceRuntime {
2791 input: &INPUT_CH,
2792 radio: &SESSION_CH,
2793 ctl: &DEVICE_CTL,
2794 out: &OUT_CH,
2795 session_gen: &SESSION_GEN,
2796 },
2797 BoardDeviceEnv {
2798 proto_store,
2799 identity_store,
2800 identity_rng,
2801 node_counters,
2802 // The driver is the only receiver; the slot count is
2803 // sized for exactly that, so this cannot fail.
2804 #[cfg(feature = "cap-battery-saadc")]
2805 battery: board_power::BATTERY_ANNOUNCE
2806 .dyn_receiver()
2807 .expect("BATTERY_ANNOUNCE receiver slot"),
2808 #[cfg(feature = "cap-gnss")]
2809 gnss_announce: umsh_ulcp_runtime::gnss::announcer()
2810 .expect("GNSS announcement receiver slot"),
2811 },
2812 )
2813 .await
2814 }
2815
2816 /// Everything the shared renderer draws that is not menu state.
2817 ///
2818 /// The device name is passed in rather than read here: reading it is
2819 /// async and the model borrows it, so the display task snapshots it
2820 /// once per frame and lends it to this.
2821 #[cfg(feature = "has-display")]
2822 fn ui_status(name: &DeviceName) -> screen::StatusModel<'_> {
2823 screen::StatusModel {
2824 device_name: core::str::from_utf8(name).unwrap_or(DEFAULT_DEVICE_NAME),
2825 battery: ui_battery(),
2826 battery_mv: ui_battery_mv(),
2827 // A live host outranks discoverability: "somebody is talking
2828 // to me" is the fact worth a row, and advertising with nobody
2829 // there is the resting state the page no longer mentions.
2830 link: match BLE_LINK.load(Ordering::Acquire) {
2831 BLE_LINK_ATTACHED => screen::LinkState::Attached,
2832 BLE_LINK_CONNECTED => screen::LinkState::Connected,
2833 _ if ADV_ALLOWED.load(Ordering::Acquire) => screen::LinkState::Advertising,
2834 _ => screen::LinkState::OffWired,
2835 },
2836 stats: ui_stats(),
2837 bonds: BLE_BOND_COUNT.load(Ordering::Acquire),
2838 // Lockout outranks the window: while locked out there is no
2839 // window to describe.
2840 pairing: if PAIRING_LOCKED_OUT.load(Ordering::Acquire) {
2841 screen::PairingState::LockedOut
2842 } else if PAIRING_MODE.load(Ordering::Acquire) {
2843 screen::PairingState::Open {
2844 pin: match PAIRING_PIN.load(Ordering::Acquire) {
2845 u32::MAX => None,
2846 pin => Some(pin),
2847 },
2848 }
2849 } else {
2850 screen::PairingState::Closed
2851 },
2852 // `None` whenever the device does not know what time it is,
2853 // which the renderer draws as nothing at all. There is
2854 // deliberately no fallback here: a placeholder would be an
2855 // indication of the current time, and a device that does not
2856 // have one must not give any.
2857 clock: umsh_hal::wall_clock::local_hhmm()
2858 .map(|(hour, minute)| screen::ClockModel { hour, minute }),
2859 }
2860 }
2861
2862 /// Radio activity for the stats page.
2863 ///
2864 /// Sampled when a frame is drawn rather than pushed: the counters move
2865 /// with every frame on the air, and waking a panel for each one would
2866 /// re-ink an e-paper display continuously to report numbers nobody is
2867 /// looking at.
2868 #[cfg(feature = "has-display")]
2869 fn ui_stats() -> screen::StatsModel {
2870 let counters = super::device_node::mac_counters();
2871 screen::StatsModel {
2872 tx_frames: counters.tx_frames,
2873 rx_frames: counters.rx_frames,
2874 rx_accepted: counters.rx_accepted,
2875 forwarded: counters.forwarded,
2876 tx_power_dbm: super::device_node::tx_power_dbm(),
2877 // The ledger's scale is 0-65535 for 0-100%; the page shows
2878 // tenths of a percent, which is the range a tracker lives in.
2879 duty_permille: (u32::from(DUTY_LEDGER.usage(Instant::now().as_millis())) * 1_000
2880 / 65_535) as u16,
2881 }
2882 }
2883
2884 /// Cached battery reading for the indicator. Reads the monitor's
2885 /// published atomics rather than requesting a sample: the request path
2886 /// is single-consumer and already belongs to the ULCP driver.
2887 #[cfg(feature = "has-display")]
2888 fn ui_battery() -> screen::BatteryIndicator {
2889 #[cfg(feature = "cap-battery-saadc")]
2890 {
2891 screen::BatteryIndicator {
2892 level_percent: board_power::battery_level(),
2893 charge: Some(umsh_ux_tracker::battery::charge_class(
2894 board_power::battery_state(),
2895 )),
2896 }
2897 }
2898 #[cfg(not(feature = "cap-battery-saadc"))]
2899 {
2900 screen::BatteryIndicator::UNKNOWN
2901 }
2902 }
2903
2904 /// Completes when the charge class or level moves, so the panel can
2905 /// redraw its indicator. Never completes on a board built without the
2906 /// SAADC monitor, which keeps the display tasks' `select` shape the
2907 /// same either way.
2908 #[cfg(feature = "has-display")]
2909 async fn battery_ui_changed() {
2910 #[cfg(feature = "cap-battery-saadc")]
2911 board_power::BATTERY_UI_CHANGED.wait().await;
2912 #[cfg(not(feature = "cap-battery-saadc"))]
2913 core::future::pending::<()>().await
2914 }
2915
2916 /// Drive the board's GNSS receiver.
2917 ///
2918 /// The whole of the per-board GNSS code: construct the UART and the
2919 /// board's power control, then hand both to the shared pump. An
2920 /// `#[embassy_executor::task]` cannot be generic, which is the only
2921 /// reason this shim exists at all — the loop it delegates to lives in
2922 /// `umsh_gnss::pump` and is common to both cargo workspaces.
2923 ///
2924 /// The receiver stays powered down until `PROP_GNSS_ENABLED` says
2925 /// otherwise, including on a board that has never been configured.
2926 #[cfg(feature = "cap-gnss")]
2927 #[embassy_executor::task]
2928 async fn gnss_task(
2929 #[allow(unused_mut)] mut uart: GnssUart,
2930 #[allow(unused_mut)] mut control: BoardGnss,
2931 ) {
2932 let Some(enable) = umsh_ulcp_runtime::gnss::EnableSource::new() else {
2933 debug_log(format_args!("gnss: enable receiver already taken"));
2934 return;
2935 };
2936
2937 // On a board whose only surviving real-time clock lives inside the
2938 // receiver, read it back before the pump takes over — otherwise a
2939 // device that was switched off knowing the time boots not knowing
2940 // it, and the clock the backup domain was kept powered to preserve
2941 // is never actually consulted.
2942 //
2943 // Gated on trust and not on `PROP_GNSS_ENABLED`, because this is a
2944 // clock operation: a device with positioning switched off still
2945 // wants to know what time it is. Waiting for the device domain is
2946 // what makes the trust flag mean the saved setting rather than the
2947 // post-reset default.
2948 #[cfg(feature = "gnss-holds-the-clock")]
2949 {
2950 umsh_ulcp_runtime::gnss::wait_configured().await;
2951 if !umsh_hal::wall_clock::is_set() && umsh_ulcp_runtime::gnss::policy().trust_time {
2952 match umsh_gnss::pump::rtc_read_once(&mut uart, &mut control, embassy_time::Delay)
2953 .await
2954 .and_then(|at| at.to_unix())
2955 {
2956 Some(epoch) => {
2957 umsh_hal::wall_clock::apply(
2958 epoch,
2959 umsh_hal::wall_clock::TimeSource::GnssRtc,
2960 true,
2961 );
2962 debug_log(format_args!("gnss: clock restored from receiver RTC"));
2963 }
2964 // What a receiver whose backup domain lost power looks
2965 // like. The device simply does not know the time.
2966 None => debug_log(format_args!("gnss: receiver RTC had no time")),
2967 }
2968 }
2969 }
2970
2971 umsh_gnss::pump::run(
2972 uart,
2973 control,
2974 enable,
2975 umsh_ulcp_runtime::gnss::FixSink,
2976 embassy_time::Delay,
2977 )
2978 .await
2979 }
2980
2981 /// Completes at the next minute boundary, so a clock row can advance.
2982 ///
2983 /// The display layer's standing rule is that panels redraw on events
2984 /// and never on a timer, because a timer on a bistable panel is a
2985 /// battery drain that reports nothing. A clock is the one thing that
2986 /// has to move on its own, so this is the sanctioned exception — and
2987 /// it is bounded to exactly the case that needs it. It never
2988 /// completes unless the panel is already awake (`awake`) *and* the
2989 /// device knows what time it is, so a sleeping panel is never woken
2990 /// by it and a device with no clock never arms it at all. A panel
2991 /// that was asleep catches up on its next event-driven redraw.
2992 #[cfg(feature = "has-display")]
2993 async fn clock_tick(awake: bool) {
2994 if !awake {
2995 core::future::pending::<()>().await;
2996 }
2997 match umsh_hal::wall_clock::millis_to_next_minute() {
2998 Some(millis) => Timer::after_millis(u64::from(millis)).await,
2999 None => core::future::pending().await,
3000 }
3001 }
3002
3003 #[cfg(feature = "has-display")]
3004 fn ui_battery_mv() -> Option<u16> {
3005 #[cfg(feature = "cap-battery-saadc")]
3006 {
3007 board_power::battery_millivolts()
3008 }
3009 #[cfg(not(feature = "cap-battery-saadc"))]
3010 {
3011 None
3012 }
3013 }
3014
3015 #[cfg(feature = "display-epd")]
3016 fn render_ui_frame(
3017 buf: &mut [u8; display::BUF_SIZE],
3018 model: &UiModel,
3019 status: &screen::StatusModel<'_>,
3020 ) {
3021 screen::render_frame(
3022 &mut display::EpdFb(buf),
3023 &screen::Layout::EPD_200X200,
3024 model,
3025 status,
3026 );
3027 }
3028
3029 #[cfg(feature = "display-epd")]
3030 fn render_message_frame(
3031 buf: &mut [u8; display::BUF_SIZE],
3032 status: &screen::StatusModel<'_>,
3033 title: &str,
3034 detail: &str,
3035 ) {
3036 screen::render_message(
3037 &mut display::EpdFb(buf),
3038 &screen::Layout::EPD_200X200,
3039 status,
3040 title,
3041 detail,
3042 );
3043 }
3044
3045 /// Everything this board's menu can do. Every display tracker in this
3046 /// family has a panel and a nav button, which between them cover the
3047 /// whole class vocabulary; a board with fewer affordances would
3048 /// return a narrower set and the menu would skip what it omits.
3049 #[cfg(feature = "has-display")]
3050 fn board_menu_items() -> MenuItems {
3051 MenuItems::all()
3052 }
3053
3054 /// Owns the e-paper bus and renders the BLE menu. Input is serialized
3055 /// through the full-refresh cycle so Select can never activate an item the
3056 /// user has not yet seen on the panel.
3057 ///
3058 /// The panel is bistable, so attention lapsing never turns it off —
3059 /// it drops whatever the user was in the middle of and returns to
3060 /// the status page, so a press after walking away starts somewhere
3061 /// whose meaning is on screen.
3062 #[cfg(feature = "display-epd")]
3063 #[embassy_executor::task]
3064 async fn display_task(
3065 mut spi: Spim<'static>,
3066 mut cs: Output<'static>,
3067 mut dc: Output<'static>,
3068 mut rst: Output<'static>,
3069 mut busy: Input<'static>,
3070 ) {
3071 let mut model = UiModel::new(board_menu_items());
3072 let mut attention = Attention::new(
3073 DisplayKind::Persistent,
3074 AttentionConfig::PERSISTENT,
3075 Instant::now().as_millis(),
3076 );
3077 let mut shown = [0xff; display::BUF_SIZE];
3078 let mut next = [0xff; display::BUF_SIZE];
3079 {
3080 let name = device_name_snapshot().await;
3081 render_ui_frame(&mut next, &model, &ui_status(&name));
3082 }
3083 display::init(&mut spi, &mut cs, &mut dc, &mut rst, &mut busy).await;
3084 display::render(&mut spi, &mut cs, &mut dc, &mut busy, &next).await;
3085 shown.copy_from_slice(&next);
3086
3087 // The panel borrows five peripherals mutably; a closure capturing
3088 // all of them would conflict with the `next` buffer it draws
3089 // into, so the push stays a macro.
3090 macro_rules! push {
3091 () => {
3092 display::render_partial(&mut spi, &mut cs, &mut dc, &mut busy, &mut shown, &next)
3093 .await
3094 };
3095 }
3096
3097 loop {
3098 // The name changes rarely but is needed by every frame this
3099 // pass might draw, including the message frames below, so it
3100 // is snapshotted once and lent out. The rest of the status is
3101 // rebuilt at each draw, since an effect handled below can
3102 // change it.
3103 let name = device_name_snapshot().await;
3104
3105 // Both holds are edge-published by other tasks, but re-deriving
3106 // them here each pass is idempotent and cannot miss an edge.
3107 let now = Instant::now().as_millis();
3108 attention.set_hold(
3109 HoldReason::Pairing,
3110 PAIRING_MODE.load(Ordering::Acquire),
3111 now,
3112 );
3113 attention.set_hold(HoldReason::Alert, alert_active(), now);
3114
3115 let lapse = async {
3116 match attention.next_deadline() {
3117 Some(deadline) => Timer::at(Instant::from_millis(deadline)).await,
3118 None => core::future::pending().await,
3119 }
3120 };
3121
3122 // True unless the arm already pushed its own frame.
3123 let mut redraw = true;
3124 match select4(
3125 UI_INPUT_CH.receive(),
3126 select4(
3127 UI_REFRESH.wait(),
3128 UI_NOTICE.wait(),
3129 UI_ALERT_CHANGED.wait(),
3130 select(battery_ui_changed(), clock_tick(attention.accepts_redraw())),
3131 ),
3132 DISPLAY_SHUTDOWN.wait(),
3133 lapse,
3134 )
3135 .await
3136 {
3137 Either4::First(input) => {
3138 debug_log(format_args!("ui input={input:?}"));
3139 attention.wake(Instant::now().as_millis());
3140 match model.apply(input) {
3141 Some(UiEffect::CheckIn) => {
3142 super::device_node::request_beacon(
3143 super::device_node::BeaconTrigger::Button,
3144 );
3145 model.set_notice(UiNotice::CheckInRequested);
3146 }
3147 Some(UiEffect::StartPairing) => {
3148 render_message_frame(
3149 &mut next,
3150 &ui_status(&name),
3151 "Starting",
3152 "pairing mode...",
3153 );
3154 push!();
3155 redraw = false;
3156 PAIRING_MODE_REQUEST.signal(());
3157 }
3158 Some(UiEffect::ClearBonds) => {
3159 render_message_frame(
3160 &mut next,
3161 &ui_status(&name),
3162 "Clearing",
3163 "bonds + PIN...",
3164 );
3165 push!();
3166 redraw = false;
3167 BLE_WIPE_REQUEST.signal(());
3168 }
3169 None => {}
3170 }
3171 }
3172 Either4::Second(refresh) => {
3173 match refresh {
3174 // The first three are consequences of something the
3175 // user or their phone just did, so all of them
3176 // count as attention.
3177 Either4::First(()) => {
3178 attention.wake(Instant::now().as_millis());
3179 model.clear_notice();
3180 }
3181 Either4::Second(notice) => {
3182 attention.wake(Instant::now().as_millis());
3183 model.set_notice(notice);
3184 }
3185 Either4::Third(()) => {
3186 attention.wake(Instant::now().as_millis());
3187 if alert_active() {
3188 render_message_frame(
3189 &mut next,
3190 &ui_status(&name),
3191 "Locate alert",
3192 "Press to stop",
3193 );
3194 push!();
3195 redraw = false;
3196 }
3197 }
3198 // A battery sample and a minute boundary are the
3199 // two things here nobody asked for, so they
3200 // redraw without counting as attention — waking
3201 // on either would reset the lapse timer forever.
3202 // The panel is bistable and already showing the
3203 // old reading, so the redraw is a partial refresh
3204 // of the indicator or the clock and little else.
3205 Either4::Fourth(_) => {}
3206 }
3207 }
3208 Either4::Third(()) => {
3209 render_message_frame(&mut next, &ui_status(&name), "Sleeping", "Good night");
3210 push!();
3211 display::sleep(&mut spi, &mut cs, &mut dc).await;
3212 DISPLAY_SHUTDOWN_DONE.signal(());
3213 core::future::pending::<()>().await;
3214 }
3215 Either4::Fourth(()) => {
3216 redraw = matches!(
3217 attention.poll(Instant::now().as_millis()),
3218 Some(Transition::Lapsed)
3219 ) && !model.is_home();
3220 if redraw {
3221 model.go_home();
3222 }
3223 }
3224 }
3225
3226 if redraw {
3227 render_ui_frame(&mut next, &model, &ui_status(&name));
3228 push!();
3229 }
3230 }
3231 }
3232
3233 /// Resolves the board's nav button (active-low, pull-up) into the
3234 /// display-tracker vocabulary: single advances, double selects, a
3235 /// 1–4 second hold released by the user goes back, and a continuing
3236 /// four-second hold always powers off.
3237 ///
3238 /// What a gesture means is decided by [`Gate`] at the press that
3239 /// starts it, not at the event that ends it, so a chord begun while
3240 /// something else owned the button is judged as a whole.
3241 #[cfg(feature = "button-nav")]
3242 #[embassy_executor::task]
3243 async fn button_task(mut button: Input<'static>) {
3244 const DEBOUNCE: Duration = Duration::from_millis(10);
3245 let mut fsm = ButtonFsm::new(umsh_ux_display_tracker::button_timings());
3246 let mut gate = Gate::new();
3247 let mut pressed = button.is_low();
3248 loop {
3249 let event = {
3250 let now_ms = Instant::now().as_millis();
3251 let edge_fut = async {
3252 if pressed {
3253 button.wait_for_high().await;
3254 Timer::after(DEBOUNCE).await;
3255 ButtonEdge::Release
3256 } else {
3257 button.wait_for_low().await;
3258 Timer::after(DEBOUNCE).await;
3259 ButtonEdge::Press
3260 }
3261 };
3262 let deadline = fsm.next_deadline().unwrap_or(now_ms.saturating_add(60_000));
3263 match select(edge_fut, Timer::at(Instant::from_millis(deadline))).await {
3264 Either::First(edge) => {
3265 pressed = matches!(edge, ButtonEdge::Press);
3266 if pressed {
3267 // Read on the press edge, not at the last loop
3268 // iteration: this task can park for a minute
3269 // awaiting an edge, and both an alert starting
3270 // and the panel lapsing dark happen during
3271 // exactly such a park.
3272 gate.set(GateReason::AlertActive, alert_active());
3273 #[cfg(feature = "display-oled")]
3274 gate.set(GateReason::ScreenOff, SCREEN_OFF.load(Ordering::Acquire));
3275 gate.on_press();
3276 // Wake on the press, not on the resolved
3277 // gesture, so the panel is already lit while
3278 // the user is still deciding what the press
3279 // will become.
3280 #[cfg(feature = "display-oled")]
3281 UI_WAKE.signal(());
3282 }
3283 fsm.on_edge(edge, Instant::now().as_millis())
3284 }
3285 Either::Second(()) => fsm.poll(Instant::now().as_millis()),
3286 }
3287 };
3288
3289 if let Some(event) = event {
3290 match gate.disposition(event) {
3291 // Whoever found the radio meant to silence it, not to
3292 // navigate its menus.
3293 Disposition::CancelAlert => INPUT_CH.send(InEvent::CancelAlert).await,
3294 Disposition::ConsumedByWake | Disposition::Discard => {}
3295 Disposition::Deliver => {
3296 let input = match event {
3297 ButtonEvent::Single => Some(UiInput::Forward),
3298 ButtonEvent::Double => Some(UiInput::Select),
3299 ButtonEvent::Long => Some(UiInput::Backward),
3300 ButtonEvent::VeryLong => {
3301 pressed = false;
3302 fsm = ButtonFsm::new(umsh_ux_display_tracker::button_timings());
3303 SHUTDOWN_SIGNAL.signal(());
3304 None
3305 }
3306 ButtonEvent::Triple | ButtonEvent::Quad => None,
3307 };
3308 if let Some(input) = input {
3309 UI_INPUT_CH.send(input).await;
3310 }
3311 }
3312 }
3313 }
3314
3315 gate.settle(fsm.next_deadline().is_none());
3316 }
3317 }
3318
3319 /// The capacitive touch button remains dedicated to the unusual e-paper
3320 /// backlight. T-Echo defines P0.11 as active-low with a pull-up: illuminate
3321 /// on a debounced low level and turn it off on the corresponding release.
3322 ///
3323 /// Deliberately outside the attention and gate models: this is a
3324 /// plain momentary light for reading a bistable panel in the dark,
3325 /// not a navigation control, so holding it neither counts as
3326 /// activity nor consumes a gesture.
3327 #[cfg(feature = "display-epd")]
3328 #[embassy_executor::task]
3329 async fn touch_task(mut touch: Input<'static>) {
3330 const DEBOUNCE: Duration = Duration::from_millis(20);
3331 loop {
3332 touch.wait_for_low().await;
3333 Timer::after(DEBOUNCE).await;
3334 if !touch.is_low() {
3335 continue;
3336 }
3337 BACKLIGHT_TOUCH.store(true, Ordering::Release);
3338 BACKLIGHT_CHANGED.signal(());
3339 debug_log(format_args!("backlight touch=true"));
3340 touch.wait_for_high().await;
3341 Timer::after(DEBOUNCE).await;
3342 BACKLIGHT_TOUCH.store(false, Ordering::Release);
3343 BACKLIGHT_CHANGED.signal(());
3344 debug_log(format_args!("backlight touch=false"));
3345 }
3346 }
3347
3348 /// Arbitrates the one bright output this board has.
3349 ///
3350 /// A locate alert outranks the touch button: the backlight is by far
3351 /// the most conspicuous thing on a T-Echo, and being conspicuous is
3352 /// the entire point of an alert. The indicator LED keeps its own
3353 /// alert blink — this adds a channel rather than moving one — and
3354 /// the touch button behaves exactly as before whenever no alert is
3355 /// running.
3356 ///
3357 /// The alert pattern is a double flash per second, which no other
3358 /// use of this pin resembles.
3359 #[cfg(feature = "display-epd")]
3360 #[embassy_executor::task]
3361 async fn backlight_task(mut backlight: Output<'static>) {
3362 const PERIOD_MS: u64 = 1_000;
3363 const STEP: Duration = Duration::from_millis(25);
3364 loop {
3365 if alert_active() {
3366 let phase = Instant::now().as_millis() % PERIOD_MS;
3367 backlight.set_level(if phase < 100 || (200..300).contains(&phase) {
3368 Level::High
3369 } else {
3370 Level::Low
3371 });
3372 // Poll rather than sleep to the next edge: the alert can
3373 // end at any moment and the pin must not be left lit.
3374 let _ = select(Timer::after(STEP), BACKLIGHT_CHANGED.wait()).await;
3375 } else {
3376 backlight.set_level(if BACKLIGHT_TOUCH.load(Ordering::Acquire) {
3377 Level::High
3378 } else {
3379 Level::Low
3380 });
3381 BACKLIGHT_CHANGED.wait().await;
3382 }
3383 }
3384 }
3385
3386 #[cfg(feature = "display-oled")]
3387 fn render_oled_frame(
3388 fb: &mut display::Sh1106Fb,
3389 model: &UiModel,
3390 status: &screen::StatusModel<'_>,
3391 ) {
3392 screen::render_frame(fb, &screen::Layout::OLED_128X64, model, status);
3393 }
3394
3395 #[cfg(feature = "display-oled")]
3396 fn render_oled_message(
3397 fb: &mut display::Sh1106Fb,
3398 status: &screen::StatusModel<'_>,
3399 title: &str,
3400 detail: &str,
3401 ) {
3402 screen::render_message(fb, &screen::Layout::OLED_128X64, status, title, detail);
3403 }
3404
3405 /// Owns the SH1106 panel and the display attention policy.
3406 ///
3407 /// The panel is emissive, so attention lapsing actually turns it off:
3408 /// dimmed as a warning at 7 s, dark at 10 s. It stays lit for as long
3409 /// as a pairing window is open, because its PIN is the only place
3410 /// that number is shown, and for as long as a locate alert runs.
3411 #[cfg(feature = "display-oled")]
3412 #[embassy_executor::task]
3413 async fn oled_display_task(mut oled: display::Sh1106<'static>) {
3414 let mut model = UiModel::new(board_menu_items());
3415 let mut attention = Attention::new(
3416 DisplayKind::Emissive,
3417 AttentionConfig::EMISSIVE,
3418 Instant::now().as_millis(),
3419 );
3420 let mut fb = display::Sh1106Fb::new();
3421 oled.init().await;
3422 {
3423 let name = device_name_snapshot().await;
3424 render_oled_frame(&mut fb, &model, &ui_status(&name));
3425 }
3426 oled.flush(&fb).await;
3427
3428 loop {
3429 // The name changes rarely but every frame this pass might draw
3430 // needs it, so it is snapshotted once and lent out; the rest of
3431 // the status is rebuilt at each draw.
3432 let name = device_name_snapshot().await;
3433
3434 // Both holds are edge-published by other tasks, but re-deriving
3435 // them here each pass is idempotent and cannot miss an edge.
3436 let now = Instant::now().as_millis();
3437 attention.set_hold(
3438 HoldReason::Pairing,
3439 PAIRING_MODE.load(Ordering::Acquire),
3440 now,
3441 );
3442 attention.set_hold(HoldReason::Alert, alert_active(), now);
3443 SCREEN_OFF.store(attention.is_lapsed(), Ordering::Release);
3444
3445 let lapse = async {
3446 match attention.next_deadline() {
3447 Some(deadline) => Timer::at(Instant::from_millis(deadline)).await,
3448 None => core::future::pending().await,
3449 }
3450 };
3451
3452 let mut redraw = false;
3453 let mut alert_frame = false;
3454 let mut transition = None;
3455 match select4(
3456 UI_INPUT_CH.receive(),
3457 select4(
3458 // All three are "content moved, redraw if the panel
3459 // is already lit"; they differ only in what they do
3460 // to the model, so they share an arm.
3461 select3(
3462 UI_REFRESH.wait(),
3463 battery_ui_changed(),
3464 clock_tick(attention.accepts_redraw()),
3465 ),
3466 UI_NOTICE.wait(),
3467 UI_WAKE.wait(),
3468 UI_ALERT_CHANGED.wait(),
3469 ),
3470 DISPLAY_SHUTDOWN.wait(),
3471 lapse,
3472 )
3473 .await
3474 {
3475 Either4::First(input) => {
3476 debug_log(format_args!("ui input={input:?}"));
3477 transition = attention.wake(Instant::now().as_millis());
3478 redraw = true;
3479 match model.apply(input) {
3480 Some(UiEffect::CheckIn) => {
3481 super::device_node::request_beacon(
3482 super::device_node::BeaconTrigger::Button,
3483 );
3484 model.set_notice(UiNotice::CheckInRequested);
3485 }
3486 Some(UiEffect::StartPairing) => PAIRING_MODE_REQUEST.signal(()),
3487 Some(UiEffect::ClearBonds) => BLE_WIPE_REQUEST.signal(()),
3488 None => {}
3489 }
3490 }
3491 Either4::Second(event) => match event {
3492 // Content the user did not ask for: redraw if the
3493 // panel is already lit, but never light it. That rule
3494 // is what keeps a battery sample from waking a tracker
3495 // in a drawer every few minutes.
3496 Either4::First(Either3::First(())) => {
3497 model.clear_notice();
3498 redraw = true;
3499 }
3500 // A battery sample and a minute boundary both move
3501 // content without touching the model.
3502 Either4::First(Either3::Second(()) | Either3::Third(())) => redraw = true,
3503 Either4::Second(notice) => {
3504 model.set_notice(notice);
3505 transition = attention.wake(Instant::now().as_millis());
3506 redraw = true;
3507 }
3508 // A wake on its own changes no content — a lit panel
3509 // is already showing the truth, and the events that do
3510 // change something raise `UI_REFRESH` alongside this.
3511 Either4::Third(()) => {
3512 transition = attention.wake(Instant::now().as_millis());
3513 redraw = transition.is_some();
3514 }
3515 // An alert takes the whole panel: being conspicuous is
3516 // the point, and the hold above keeps it lit until the
3517 // alert ends.
3518 Either4::Fourth(()) => {
3519 transition = attention.wake(Instant::now().as_millis());
3520 redraw = true;
3521 alert_frame = alert_active();
3522 }
3523 },
3524 Either4::Third(()) => {
3525 render_oled_message(
3526 &mut fb,
3527 &ui_status(&name),
3528 "Powering off",
3529 "press to wake",
3530 );
3531 oled.flush(&fb).await;
3532 oled.set_contrast(display::CONTRAST_NORMAL).await;
3533 oled.set_display_on(true).await;
3534 Timer::after(Duration::from_millis(1_200)).await;
3535 oled.set_display_on(false).await;
3536 DISPLAY_SHUTDOWN_DONE.signal(());
3537 core::future::pending::<()>().await;
3538 }
3539 Either4::Fourth(()) => transition = attention.poll(Instant::now().as_millis()),
3540 }
3541
3542 match transition {
3543 Some(Transition::Lapsed) => {
3544 // Waking always lands on the status page rather than
3545 // on whatever was abandoned here.
3546 model.go_home();
3547 oled.set_display_on(false).await;
3548 redraw = false;
3549 }
3550 Some(Transition::Dimmed) => {
3551 oled.set_contrast(display::CONTRAST_DIM).await;
3552 redraw = false;
3553 }
3554 Some(Transition::Woke) | None => {}
3555 }
3556
3557 if redraw && attention.accepts_redraw() {
3558 let status = ui_status(&name);
3559 if alert_frame {
3560 render_oled_message(&mut fb, &status, "Locate alert", "Press to stop");
3561 } else {
3562 render_oled_frame(&mut fb, &model, &status);
3563 }
3564 oled.flush(&fb).await;
3565 }
3566 // Ordered after the redraw so the panel never lights on a
3567 // stale frame.
3568 if matches!(transition, Some(Transition::Woke)) {
3569 oled.set_contrast(display::CONTRAST_NORMAL).await;
3570 oled.set_display_on(true).await;
3571 }
3572 }
3573 }
3574
3575 /// Wio Tracker L1 piezo driver. Kept as a task shim so the BSP's
3576 /// generic async runner is monomorphized in this binary.
3577 #[cfg(feature = "cap-buzzer")]
3578 #[embassy_executor::task]
3579 async fn wio_buzzer_task(pwm: SimplePwm<'static>) {
3580 umsh_bsp_wio_tracker_l1::buzzer::run(pwm).await;
3581 }
3582
3583 /// T-1000E piezo driver. Kept as a task shim so the BSP's generic async
3584 /// runner is monomorphized in this binary.
3585 #[cfg(feature = "t1000e")]
3586 #[embassy_executor::task]
3587 async fn t1000e_buzzer_task(
3588 pwm: SimplePwm<'static>,
3589 enable: Output<'static>,
3590 initially_silenced: bool,
3591 ) {
3592 umsh_bsp_t1000e::buzzer::run(pwm, enable, initially_silenced).await;
3593 }
3594
3595 /// Apply the T-1000E device profile: the unsupported single and quadruple
3596 /// slots remain inert, double-click toggles persisted Silence State,
3597 /// triple-click is reserved for unsupported GPS control, and the
3598 /// three-second long press enters persisted Sleep State. A startup-held press has already
3599 /// been consumed by the force-pairing ceremony, so it is ignored through
3600 /// its release instead of becoming an immediate shutdown.
3601 #[cfg(feature = "t1000e")]
3602 #[embassy_executor::task]
3603 async fn t1000e_button_task(
3604 mut button: Input<'static>,
3605 held_at_boot: bool,
3606 mut ux_store: ProtoStore,
3607 ) {
3608 const DEBOUNCE: Duration = Duration::from_millis(10);
3609
3610 if held_at_boot {
3611 button.wait_for_low().await;
3612 Timer::after(DEBOUNCE).await;
3613 }
3614
3615 let mut fsm = ButtonFsm::new(ButtonTimings::default());
3616 let mut pressed = false;
3617 loop {
3618 let event = {
3619 let now_ms = Instant::now().as_millis();
3620 let edge_fut = async {
3621 if pressed {
3622 button.wait_for_low().await;
3623 Timer::after(DEBOUNCE).await;
3624 ButtonEdge::Release
3625 } else {
3626 button.wait_for_high().await;
3627 Timer::after(DEBOUNCE).await;
3628 ButtonEdge::Press
3629 }
3630 };
3631 let deadline = fsm.next_deadline().unwrap_or(now_ms.saturating_add(60_000));
3632 match select(edge_fut, Timer::at(Instant::from_millis(deadline))).await {
3633 Either::First(edge) => {
3634 pressed = matches!(edge, ButtonEdge::Press);
3635 // A press means eyes on the LED and, likely, an
3636 // environment that just changed — a device pulled
3637 // from a pocket should not confirm at last
3638 // minute's brightness. Re-evaluate ambient light
3639 // now: the ~80 ms measurement completes well
3640 // inside click recognition, so whatever
3641 // confirmation follows renders at the fresh
3642 // level, and at press-down the LED is almost
3643 // certainly in a dark phase, so the sampler's
3644 // blanking is invisible. User-initiated, so it
3645 // deliberately bypasses the battery cadence.
3646 if pressed {
3647 umsh_bsp_t1000e::light::request_sample();
3648 }
3649 fsm.on_edge(edge, Instant::now().as_millis())
3650 }
3651 Either::Second(()) => fsm.poll(Instant::now().as_millis()),
3652 }
3653 };
3654
3655 // Whoever found the beeping radio gets to silence it with
3656 // whatever they press first, and that press does nothing
3657 // else — fumbling for an alarm must not fire off a beacon or
3658 // flip the silence preference. The long press is the
3659 // exception the spec allows: powering the radio off is
3660 // deliberate enough to mean it, and it ends the alert anyway.
3661 if event.is_some() && alert_active() && !matches!(event, Some(ButtonEvent::Long)) {
3662 INPUT_CH.send(InEvent::CancelAlert).await;
3663 continue;
3664 }
3665
3666 match event {
3667 Some(ButtonEvent::Single) => {
3668 // Primary action: beacon from the device identity. The
3669 // node task emits the confirmation (LED + melody) only
3670 // when the MAC accepts the send; with no identity the
3671 // node is dormant and the slot stays inert, with no
3672 // false confirmation.
3673 super::device_node::request_beacon(super::device_node::BeaconTrigger::Button);
3674 }
3675 Some(ButtonEvent::Double) => {
3676 let preferences = umsh_bsp_t1000e::preferences::toggle_silent();
3677 umsh_bsp_t1000e::BUZZER_SILENCE_SET.signal(preferences.silent);
3678 umsh_bsp_t1000e::indicator::LED_SEQUENCE_SIGNAL
3679 .signal(LedSequence::ActionConfirm);
3680 let _ = persist_ux_preferences(&mut ux_store, preferences).await;
3681 }
3682 Some(ButtonEvent::Triple) => {
3683 // The receiver switch, which the UX profile reserves
3684 // this slot for. Routed through the ULCP session rather
3685 // than straight at the pins, so the property, an
3686 // attached host and the saved snapshot all see the same
3687 // flip — poking the driver here would be undone by the
3688 // next device-domain sync. A build with no receiver
3689 // leaves the slot inert, confirmation included.
3690 //
3691 // Nothing is indicated here: the press does not know
3692 // which way the switch went, and the session answers
3693 // that through `gnss_switched`.
3694 #[cfg(feature = "cap-gnss")]
3695 INPUT_CH.send(InEvent::ToggleGnss).await;
3696 }
3697 Some(ButtonEvent::Long) => {
3698 pressed = false;
3699 fsm = ButtonFsm::new(ButtonTimings::default());
3700 let preferences = umsh_bsp_t1000e::preferences::set_asleep(true);
3701 let _ = persist_ux_preferences(&mut ux_store, preferences).await;
3702 umsh_bsp_t1000e::SHUTDOWN_SIGNAL.signal(());
3703 }
3704 Some(ButtonEvent::Quad | ButtonEvent::VeryLong) | None => {}
3705 }
3706 }
3707 }
3708
3709 #[cfg(feature = "t1000e")]
3710 #[embassy_executor::task]
3711 async fn t1000e_shutdown_task() -> ! {
3712 umsh_bsp_t1000e::shutdown::run().await
3713 }
3714
3715 #[cfg(feature = "t1000e")]
3716 #[embassy_executor::task]
3717 async fn t1000e_power_task(
3718 saadc: Peri<'static, peripherals::SAADC>,
3719 battery_pin: Peri<'static, peripherals::P0_02>,
3720 light_pin: Peri<'static, peripherals::P0_29>,
3721 sensor_rail: Output<'static>,
3722 sensor_enable: Output<'static>,
3723 external_power: Input<'static>,
3724 charge_active: Input<'static>,
3725 ) {
3726 // The BSP builds a single-channel converter per measurement — the
3727 // battery's and the light sensor's configurations have nothing in
3728 // common — so it takes the peripheral and `Irqs` rather than a
3729 // built `Saadc`. This shim is where `Irqs` is named concretely.
3730 umsh_bsp_t1000e::power::run_battery_monitor(
3731 saadc,
3732 Irqs,
3733 battery_pin,
3734 light_pin,
3735 sensor_rail,
3736 sensor_enable,
3737 external_power,
3738 charge_active,
3739 )
3740 .await;
3741 }
3742
3743 /// SenseCAP Solar battery monitor task: SAADC + active-low divider gate.
3744 /// No charge-detect / external-power GPIO (see BSP `power` module).
3745 #[cfg(feature = "board-sensecap-solar")]
3746 #[embassy_executor::task]
3747 async fn sensecap_power_task(saadc: Saadc<'static, 1>, divider_gate: Output<'static>) {
3748 umsh_bsp_sensecap_solar::power::run_battery_monitor(saadc, divider_gate).await;
3749 }
3750
3751 /// XIAO nRF52840 kit battery monitor task: SAADC plus three held
3752 /// pins. The divider is **ungated** — P0.14 is its low side and is
3753 /// driven LOW for the life of the program, because both alternatives
3754 /// exceed P0.31's absolute maximum (see the BSP `power` module). The
3755 /// BQ25100 does report its own state, so unlike the other boards here
3756 /// this one distinguishes charging from charge-complete.
3757 #[cfg(feature = "board-xiao-nrf52")]
3758 #[embassy_executor::task]
3759 async fn xiao_power_task(
3760 saadc: Saadc<'static, 1>,
3761 divider_low: Output<'static>,
3762 charge_status_n: Input<'static>,
3763 charge_current_hi: Output<'static>,
3764 ) {
3765 umsh_bsp_xiao_nrf52::power::run_battery_monitor(
3766 saadc,
3767 divider_low,
3768 charge_status_n,
3769 charge_current_hi,
3770 )
3771 .await;
3772 }
3773
3774 /// Headless System OFF for the XIAO nRF52840 kit. The sole producer
3775 /// is the BSP's protective low-battery cutoff: this board has no
3776 /// button to hold, and there is no remote power-off command in this
3777 /// firmware. Nothing is armed as a wake source either — there is
3778 /// nothing on the board to arm. See the BSP `shutdown` module.
3779 #[cfg(feature = "board-xiao-nrf52")]
3780 #[embassy_executor::task]
3781 async fn xiao_shutdown_task() -> ! {
3782 umsh_bsp_xiao_nrf52::shutdown::run().await
3783 }
3784
3785 /// T-Echo battery monitor task: SAADC only. The divider is hard-wired
3786 /// (no gate) and the charger exposes no status pin, so external power
3787 /// comes from usbregstatus (see BSP `power` module).
3788 #[cfg(feature = "board-techo")]
3789 #[embassy_executor::task]
3790 async fn techo_power_task(saadc: Saadc<'static, 1>) {
3791 umsh_bsp_techo::power::run_battery_monitor(saadc).await;
3792 }
3793
3794 /// Wio Tracker L1 battery monitor task: SAADC + **active-high**
3795 /// divider gate (P0.04 / `BAT_READ`). The charger exposes no status
3796 /// pin, so external power comes from usbregstatus (see BSP `power`
3797 /// module).
3798 #[cfg(feature = "board-wio-tracker-l1")]
3799 #[embassy_executor::task]
3800 async fn wio_power_task(saadc: Saadc<'static, 1>, divider_gate: Output<'static>) {
3801 umsh_bsp_wio_tracker_l1::power::run_battery_monitor(saadc, divider_gate).await;
3802 }
3803
3804 /// Controlled power-off for the Wio Tracker L1: blank the OLED, hold
3805 /// the radio in reset, tri-state the peripheral signal pins, and
3806 /// enter System OFF with the nav button armed as the wake source.
3807 ///
3808 /// This board has a mechanical power switch, so System OFF is a
3809 /// convenience rather than the only way to stop the drain — but it is
3810 /// still what keeps the protective low-battery cutoff from letting an
3811 /// unattended pack deep-discharge with the switch left on.
3812 ///
3813 /// Unlike the T-Echo there is no board-wide peripheral rail to drop;
3814 /// the hardware reconstruction found no equivalent of that board's
3815 /// P0.12. So, like the SenseCAP Solar (the other rail-less SX1262
3816 /// board), the radio is parked by holding RST low — driven outputs
3817 /// keep their level through System OFF — and everything else is
3818 /// tri-stated.
3819 #[cfg(feature = "system-off-wio")]
3820 #[embassy_executor::task]
3821 async fn wio_shutdown_task() -> ! {
3822 // Two producers: the nav button's four-second hold (the local
3823 // signal) and the battery monitor's protective low-voltage cutoff
3824 // (the BSP's). Either one runs the same teardown.
3825 let _ = select(
3826 SHUTDOWN_SIGNAL.wait(),
3827 umsh_bsp_wio_tracker_l1::power::SHUTDOWN_SIGNAL.wait(),
3828 )
3829 .await;
3830
3831 DISPLAY_SHUTDOWN.signal(());
3832 let _ = select(
3833 DISPLAY_SHUTDOWN_DONE.wait(),
3834 Timer::after(Duration::from_secs(5)),
3835 )
3836 .await;
3837
3838 // The usual trigger is the nav button's four-second hold, which
3839 // means the button is often still down right now — and it is also
3840 // the wake pin. Arming DETECT-low while it is held would wake the
3841 // chip the instant it powers off, so wait for the release first
3842 // (plus a debounce margin), the same dance the SenseCAP Solar
3843 // does with its power button.
3844 connect_input(Port::P0, 8, WakePull::Up);
3845 while !read_pin(Port::P0, 8) {
3846 Timer::after(Duration::from_millis(50)).await;
3847 }
3848 Timer::after(Duration::from_millis(50)).await;
3849
3850 // No switchable rail, so the SX1262 would otherwise keep whatever
3851 // mode it was in — typically continuous RX at milliamps — under a
3852 // System OFF that draws microamps. Holding RST (active-low) low
3853 // collapses it to its reset-state minimum.
3854 drive_pin_low(Port::P1, 7);
3855 // Battery divider gate is active-high: drive it LOW (disconnected)
3856 // rather than tri-stating, so the FET gate is pinned instead of
3857 // floating and the divider's quiescent draw is provably gone.
3858 drive_pin_low(Port::P0, 4);
3859 // The L76K GNSS shares the always-on rail, so System OFF does not
3860 // reach it: its standby line (active-high wake) is the only thing
3861 // that decides whether the board's floor is microamps or the tens
3862 // of milliamps an acquiring receiver draws. The BSP has driven it
3863 // since boot and the pump leaves it wherever `PROP_GNSS_ENABLED`
3864 // last put it, so this is only the belt to that suspenders — but
3865 // it has to happen here, while the module can still act on it.
3866 //
3867 // Deliberately *not* a full teardown: the module keeps its power
3868 // and its backup domain through System OFF, which is where this
3869 // board's clock comes from on the next boot. Asking it to sleep is
3870 // the whole intent; taking anything else away would cost the time.
3871 drive_pin_low(Port::P1, 9);
3872 // Three more control lines that drive real loads. Same argument as
3873 // the divider gate above: a tri-stated gate is not a gate that is
3874 // provably off, and driven levels are retained through System OFF.
3875 drive_pin_low(Port::P1, 8); // RXEN, active-high → LNA unbiased
3876 drive_pin_low(Port::P1, 1); // user LED, active-high
3877 drive_pin_low(Port::P1, 0); // piezo
3878
3879 // OLED I²C (TWIM0): SDA=P0.06, SCL=P0.05
3880 // Radio SPI (TWISPI1): SCK=P0.30, MISO=P0.03, MOSI=P0.28
3881 // Radio control: CS=P1.14, BUSY=P1.10, DIO1=P0.07
3882 // (RST, RXEN, LED, and piezo pinned above)
3883 // The display, radio, and battery tasks still own these pins;
3884 // direct PIN_CNF writes are deliberate here because every task is
3885 // about to lose its clock.
3886 for (port, pin) in [
3887 (Port::P0, 6u8),
3888 (Port::P0, 5u8),
3889 (Port::P0, 30u8),
3890 (Port::P0, 3u8),
3891 (Port::P0, 28u8),
3892 (Port::P1, 14u8),
3893 (Port::P1, 10u8),
3894 (Port::P0, 7u8), // radio DIO1 ← has SENSE set by async radio wait
3895 ] {
3896 tristate_pin(port, pin);
3897 }
3898
3899 // P0.08 is the nav button. Active-low, pull-up → DETECT-low wakes.
3900 power_off(&[WakePin {
3901 port: Port::P0,
3902 pin: 8,
3903 sense: WakeSense::Low,
3904 }])
3905 }
3906
3907 /// Dedicated power-button (P1.01, active-low) state machine for the
3908 /// Solar P1. This board has a button reserved for power, so — unlike the
3909 /// single-button boards that overload one button into a gesture FSM — it
3910 /// drives *nothing but power*: a hold past `HOLD_OFF` acknowledges on
3911 /// LED_A and requests System OFF, and a short press does nothing at all.
3912 /// Everything a user might otherwise want from a press is on USR; see
3913 /// [`sensecap_usr_button_task`]. Powering back on happens by pressing USR
3914 /// while in System OFF — a PWR press there reaches the bootloader instead.
3915 #[cfg(feature = "power-button")]
3916 #[embassy_executor::task]
3917 async fn sensecap_pwr_button_task(mut button: Input<'static>) {
3918 const HOLD_OFF: Duration = Duration::from_millis(1500);
3919 const DEBOUNCE: Duration = Duration::from_millis(20);
3920
3921 // If PWR is still held when we boot, ignore it through release so the
3922 // press that started us is not misread as an immediate power-off hold.
3923 // (The force-pairing ceremony is on USR/P1.07, not this button, so it
3924 // never reaches here.)
3925 if button.is_low() {
3926 button.wait_for_high().await;
3927 Timer::after(DEBOUNCE).await;
3928 }
3929
3930 loop {
3931 button.wait_for_low().await;
3932 Timer::after(DEBOUNCE).await;
3933 if button.is_high() {
3934 continue; // bounce
3935 }
3936 // Power off only if held past HOLD_OFF; release before that is a
3937 // short press, which this button deliberately ignores.
3938 match select(button.wait_for_high(), Timer::after(HOLD_OFF)).await {
3939 Either::First(()) => {}
3940 Either::Second(()) => {
3941 // Hold accepted: acknowledge on LED_A and wait for the
3942 // blinks to finish before tearing the board down, or the
3943 // teardown would cut the acknowledgement it just asked
3944 // for. Bounded, so a wedged indicator cannot block
3945 // powering off.
3946 ATTENTION_LED.signal(LedSequence::PowerOff);
3947 let _ = select(
3948 ATTENTION_LED_DONE.wait(),
3949 Timer::after(Duration::from_millis(1500)),
3950 )
3951 .await;
3952 umsh_bsp_sensecap_solar::power::SHUTDOWN_SIGNAL.signal(());
3953 // The shutdown task waits for PWR release before arming
3954 // wake; park here until it powers us off.
3955 button.wait_for_high().await;
3956 }
3957 }
3958 }
3959 }
3960
3961 /// The user button (USR / P1.07, active-low) on the Solar P1.
3962 ///
3963 /// This is the board's whole interactive surface while running — PWR
3964 /// does power and nothing else — so it carries the primary-action slot
3965 /// the UX profile describes: a press asks the device node to beacon,
3966 /// putting a signed identity (with its position, when the identity
3967 /// auto-update is on) on the air.
3968 ///
3969 /// Except while the locate alert is running, when the first press
3970 /// silences it and does nothing else. Whoever found the blinking radio
3971 /// gets to stop it with whatever they press; fumbling for it must not
3972 /// also fire off a beacon.
3973 ///
3974 /// No confirmation is emitted here. The node answers an accepted send
3975 /// through `NodeHooks::beacon_confirm`, so a board with no identity —
3976 /// where the node is dormant and the slot is genuinely inert — stays
3977 /// silent rather than acknowledging something that did not happen.
3978 #[cfg(feature = "power-button")]
3979 #[embassy_executor::task]
3980 async fn sensecap_usr_button_task(mut button: Input<'static>) {
3981 const DEBOUNCE: Duration = Duration::from_millis(20);
3982
3983 // The press that woke the board from System OFF, or the one that
3984 // ran the force-pairing ceremony, is still down. Neither is a
3985 // beacon request.
3986 if button.is_low() {
3987 button.wait_for_high().await;
3988 Timer::after(DEBOUNCE).await;
3989 }
3990
3991 loop {
3992 button.wait_for_low().await;
3993 Timer::after(DEBOUNCE).await;
3994 if button.is_high() {
3995 continue; // bounce
3996 }
3997 if alert_active() {
3998 INPUT_CH.send(InEvent::CancelAlert).await;
3999 } else {
4000 super::device_node::request_beacon(super::device_node::BeaconTrigger::Button);
4001 }
4002 // One action per press, however long it is held.
4003 button.wait_for_high().await;
4004 Timer::after(DEBOUNCE).await;
4005 }
4006 }
4007
4008 /// One-shot sequences for LED_A, the Solar P1's attention indicator.
4009 #[cfg(feature = "power-button")]
4010 static ATTENTION_LED: Signal<ThreadModeRawMutex, LedSequence> = Signal::new();
4011
4012 /// Fires when a requested sequence has finished playing, so a caller
4013 /// that is about to take the board down can let it finish.
4014 #[cfg(feature = "power-button")]
4015 static ATTENTION_LED_DONE: Signal<ThreadModeRawMutex, ()> = Signal::new();
4016
4017 /// Confirm an accepted local action on LED_A. Reachable as a plain
4018 /// `fn()` because that is the shape `NodeHooks` takes.
4019 #[cfg(feature = "power-button")]
4020 pub fn confirm_attention_action() {
4021 ATTENTION_LED.signal(LedSequence::ActionConfirm);
4022 }
4023
4024 /// Drives LED_A (P0.15, white, active-high) on the Solar P1.
4025 ///
4026 /// The board has two LEDs and gives them separate jobs. LED_B (blue) is
4027 /// the status light: heartbeat, BLE pairing blink — the "this thing is
4028 /// alive, here is its link state" story, which is worth glancing at and
4029 /// not worth looking up for. LED_A is the one meant to catch an eye
4030 /// across a field: the locate alert, and the short confirmations that
4031 /// answer a button press.
4032 ///
4033 /// It idles dark. A second heartbeat would only compete with the first.
4034 #[cfg(feature = "power-button")]
4035 #[embassy_executor::task]
4036 async fn sensecap_attention_led_task(mut led: Output<'static>) -> ! {
4037 let mut engine = LedEngine::attention_only(Instant::now().as_millis());
4038 loop {
4039 // On a board with no buzzer the blink is the entire alert, and
4040 // it outranks the confirmations inside the engine.
4041 if alert_active() {
4042 engine.start_alert(Instant::now().as_millis());
4043 } else {
4044 engine.stop_alert();
4045 }
4046
4047 let decision = engine.tick(Instant::now().as_millis());
4048 if decision.on {
4049 led.set_high();
4050 } else {
4051 led.set_low();
4052 }
4053 // Nothing pending and nothing to show: report the sequence
4054 // finished, for whoever is waiting on it before powering off.
4055 if !decision.on && !engine.alert_active() {
4056 ATTENTION_LED_DONE.signal(());
4057 }
4058
4059 match select3(
4060 Timer::at(Instant::from_millis(decision.next_deadline_ms)),
4061 ALERT_CHANGED.wait(),
4062 ATTENTION_LED.wait(),
4063 )
4064 .await
4065 {
4066 Either3::Third(sequence) => {
4067 ATTENTION_LED_DONE.reset();
4068 engine.play(sequence, Instant::now().as_millis());
4069 }
4070 Either3::First(()) | Either3::Second(()) => {}
4071 }
4072 }
4073 }
4074
4075 #[cfg(feature = "power-button")]
4076 #[embassy_executor::task]
4077 async fn sensecap_shutdown_task() -> ! {
4078 umsh_bsp_sensecap_solar::shutdown::run().await
4079 }
4080
4081 /// Controlled power-off: put the e-paper controller to sleep, tri-state
4082 /// peripheral signal pins, drop the rail, and enter System OFF.
4083 #[cfg(feature = "system-off-techo")]
4084 #[embassy_executor::task]
4085 async fn shutdown_task(peripheral_power: Output<'static>, power_enable: Output<'static>) -> ! {
4086 // Two producers on the T-Echo: the button's four-second hold (the
4087 // local signal) and the battery monitor's protective low-voltage
4088 // cutoff (the BSP's). Either one runs the same teardown.
4089 #[cfg(feature = "board-techo")]
4090 let _ = select(
4091 SHUTDOWN_SIGNAL.wait(),
4092 umsh_bsp_techo::power::SHUTDOWN_SIGNAL.wait(),
4093 )
4094 .await;
4095 #[cfg(not(feature = "board-techo"))]
4096 SHUTDOWN_SIGNAL.wait().await;
4097
4098 DISPLAY_SHUTDOWN.signal(());
4099 let _ = select(
4100 DISPLAY_SHUTDOWN_DONE.wait(),
4101 Timer::after(Duration::from_secs(5)),
4102 )
4103 .await;
4104
4105 // The usual trigger is the side button's four-second hold, which
4106 // means the button is often still down right now — and it is also
4107 // the wake pin. Arming DETECT-low while it is held would wake the
4108 // chip the instant it powers off, so wait for the release first
4109 // (plus a debounce margin), the same dance the SenseCAP Solar
4110 // does with its power button.
4111 connect_input(Port::P1, 10, WakePull::Up);
4112 while !read_pin(Port::P1, 10) {
4113 Timer::after(Duration::from_millis(50)).await;
4114 }
4115 Timer::after(Duration::from_millis(50)).await;
4116
4117 // Nothing below this point awaits, so the heartbeat task cannot run
4118 // again and take the status LED back.
4119 //
4120 // The LED and the e-paper backlight are the two pins still driving a
4121 // load, and driven levels are retained through System OFF. Both are
4122 // pinned to their off state rather than tri-stated: their loads hang
4123 // off the always-on rail, where a floating pin is not provably dark.
4124 // The remaining RGB channel (P0.15) is never configured by this
4125 // firmware, so it sits at reset — a disconnected input that cannot
4126 // sink the LED. (P0.13, which the Meshtastic/MeshCore variant files
4127 // call the red channel, is PWR_EN per the schematic and is handled
4128 // with the rail below.)
4129 drive_pin_high(Port::P0, 14); // status LED, active-low → high is off
4130 drive_pin_low(Port::P1, 11); // e-paper backlight, active-high
4131
4132 // The L76K GNSS. Dropping the rail below unpowers it on battery, but
4133 // not on USB: VBUS keeps VDD_POWR alive through a path PWR_EN does
4134 // not gate (hw-observed 2026-08-06), so this state must be correct
4135 // for a module that stays powered indefinitely, not just for one
4136 // about to lose its rail.
4137 //
4138 // Standby/WAKEUP (P1.02) is internally pulled up — floating means
4139 // awake — so it is driven low: a valid logic low into a powered
4140 // module (Standby, its proper low-power state) and no current into
4141 // an unpowered one. Reset (P1.05) is tri-stated, NOT driven: the
4142 // L76K hardware design has RESET_N internally pulled up ("leave
4143 // N/C if unused"), so released it idles high on a powered module —
4144 // holding it low instead pinned the powered module in reset, its
4145 // *worst* state, with the PPS pull-up faintly lighting the internal
4146 // blue LED as the tell. The UART line into the module (P1.08) is
4147 // driven low: low is a legal idle-adjacent level either way,
4148 // whereas its usual high idle would back-power a dead module.
4149 // P1.09 carries the module's output and is never driven by this
4150 // chip, so it is only released.
4151 drive_pin_low(Port::P1, 2);
4152 tristate_pin(Port::P1, 5);
4153 drive_pin_low(Port::P1, 8);
4154 tristate_pin(Port::P1, 9);
4155
4156 // E-paper SPI bus (SPIM2): SCK=P0.31, MISO=P1.07, MOSI=P0.29
4157 // E-paper control: CS=P0.30, DC=P0.28, RST=P0.02, BUSY=P0.03
4158 // Radio SPI bus (TWISPI1): SCK=P0.19, MOSI=P0.22, MISO=P0.23
4159 // Radio control: CS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20
4160 // The display and touch tasks still own these pins; direct PIN_CNF
4161 // writes are deliberate here because every task is about to lose power.
4162 for (port, pin) in [
4163 (Port::P0, 31u8),
4164 (Port::P1, 7u8),
4165 (Port::P0, 29u8),
4166 (Port::P0, 30u8),
4167 (Port::P0, 28u8),
4168 (Port::P0, 2u8),
4169 (Port::P0, 3u8),
4170 (Port::P0, 11u8), // touch input ← async wait may have set SENSE
4171 (Port::P0, 19u8),
4172 (Port::P0, 22u8),
4173 (Port::P0, 23u8),
4174 (Port::P0, 24u8),
4175 (Port::P0, 25u8),
4176 (Port::P0, 17u8),
4177 (Port::P0, 20u8), // radio DIO1 ← has SENSE set by async radio wait
4178 ] {
4179 tristate_pin(port, pin);
4180 }
4181
4182 // The rail is switched by two pins, not one: per the schematic,
4183 // SX1262 = PWR_EN (P0.13), VDD_POWR = PWR_EN ∧ (PWR_ON (P0.12)
4184 // ∨ VBUS). PWR_EN is the master gate — and because VBUS stands in
4185 // for PWR_ON, it is the only input that keeps "off" off while the
4186 // board is on USB. (The Meshtastic/MeshCore variant files call
4187 // P0.13 the red LED channel; the schematic disagrees, and it was
4188 // the schematic that explained the off-state symptom: with PWR_EN
4189 // left floating, the rail only half-collapsed, and the L76K sat
4190 // browned-up with its PPS pull-up faintly lighting the internal
4191 // blue LED.)
4192 //
4193 // Dropping the `Output`s only hands the pins back to embassy,
4194 // which writes PIN_CNF = INPUT:Disconnect with no pull — floating,
4195 // the same trap. Pin both low so the LoRa module, GNSS, sensors,
4196 // and e-paper bias generator are provably unpowered rather than
4197 // left to a floating gate.
4198 drop(peripheral_power);
4199 drop(power_enable);
4200 drive_pin_low(Port::P0, 12);
4201 drive_pin_low(Port::P0, 13);
4202
4203 // P1.10 is the side user button. Active-low, pull-up → DETECT-low wakes.
4204 power_off(&[WakePin {
4205 port: Port::P1,
4206 pin: 10,
4207 sense: WakeSense::Low,
4208 }])
4209 }
4210
4211 // ─── Main ────────────────────────────────────────────────────────────────
4212
4213 #[embassy_executor::main]
4214 async fn main(spawner: Spawner) {
4215 // Temporary freeze diagnostics: recover the previous boot's last
4216 // breadcrumb stage, then mark progress through boot. Stage map:
4217 // 1 main entered 8 USB built, core tasks spawned
4218 // 2 embassy init done 9 chirp signaled / pre-join
4219 // 3 WDT armed 10 ble_app entered
4220 // 4 radio ready 11 trouble stack + GATT server built
4221 // 5 MPSL ready 12 advertising loop reached
4222 // 6 bond store ready 13 usb.run() first polled
4223 // 7 SDC built
4224 // Point the shared runtime's log seam at this board's debug
4225 // channel, before anything shared runs. `debug_log` itself
4226 // buffers until a transport is up, so installing it this early
4227 // costs nothing and means the journal mount lines are not lost.
4228 umsh_ulcp_runtime::log::set_debug_log(debug_log);
4229
4230 // Re-power the GNSS backup domain as the very first thing this
4231 // image does, before embassy init and before any peripheral is
4232 // touched.
4233 //
4234 // nRF52840 System OFF retains driven pin levels *while it is off*,
4235 // but waking from it is a reset: GPIO returns to its disconnected
4236 // reset configuration, and stays there until something drives it
4237 // again. On this board that pin gates the only real-time clock
4238 // there is, so every millisecond between the reset and this write
4239 // is a millisecond the clock is running on whatever charge is left
4240 // on the rail. Asserting it in the normal peripheral-init block —
4241 // after the bootloader, embassy, the radio and the journal — is
4242 // far too late to expect it to survive.
4243 //
4244 // Whether it survives even from here is a question about the
4245 // bootloader's own startup time and the rail's capacitance, not
4246 // about this firmware. If it does not, the receiver comes back
4247 // reporting its own epoch, which `umsh-gnss` rejects, and the
4248 // device honestly reports that it does not know the time.
4249 #[cfg(all(feature = "gnss-holds-the-clock", feature = "t1000e"))]
4250 umsh_bsp_nrf52840::system_off::drive_pin_high(umsh_bsp_nrf52840::system_off::Port::P0, 8);
4251
4252 let (previous_crumb, previous_beats) = super::panic::breadcrumb_take();
4253 PREV_BOOT_CRUMB.store(previous_crumb, Ordering::Release);
4254 PREV_BOOT_BEATS.store(previous_beats, Ordering::Release);
4255 let wdt_capture = super::panic::wdt_capture_take();
4256 let mut pc_ring = [0u32; super::panic::PC_RING_ENTRIES];
4257 let pc_ring_count = super::panic::pc_ring_take(&mut pc_ring);
4258 PREV_RING_COUNT.store(pc_ring_count as u16, Ordering::Release);
4259 super::panic::breadcrumb_mark(1);
4260
4261 // Init heap before any alloc-using code (the device node's
4262 // bring-up allocates a small bounded amount). 8 KiB matches the
4263 // CLI firmware's budget for the same node stack.
4264 {
4265 use core::mem::MaybeUninit;
4266 const HEAP_SIZE: usize = 8192;
4267 static mut HEAP: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
4268 unsafe { crate::ALLOCATOR.init(core::ptr::addr_of!(HEAP) as usize, HEAP_SIZE) }
4269 }
4270
4271 // Crystal-less boards (XIAO-based SenseCAP Solar) run the LFCLK from
4272 // the internal RC oscillator; boards with a 32.768 kHz crystal use it.
4273 #[cfg(not(feature = "lfclk-rc"))]
4274 let p = embassy_nrf::init(umsh_bsp_nrf52840::clocks::ble_config());
4275 #[cfg(feature = "lfclk-rc")]
4276 let p = embassy_nrf::init(umsh_bsp_nrf52840::clocks::ble_config_lfrc());
4277 // GPIO state survives the soft reset/DFU handoff. Silence the T-1000E
4278 // piezo before any potentially lengthy radio, flash, or BLE work so a
4279 // retained PWM/enable state cannot sound until the buzzer task starts.
4280 #[cfg(feature = "t1000e")]
4281 {
4282 drive_pin_low(Port::P0, 25); // buzzer PWM input
4283 drive_pin_low(Port::P1, 5); // buzzer driver enable
4284 }
4285 super::panic::breadcrumb_mark(2);
4286 // RESETREAS survives reset. Capture and clear it before starting the
4287 // watchdog so a later host query can distinguish a watchdog reboot
4288 // from a cold start or an external reset.
4289 let hardware_reset_reasons = pac::POWER.resetreas().read();
4290 pac::POWER.resetreas().write(|reasons| reasons.0 = u32::MAX);
4291 BOOT_RESETREAS.store(hardware_reset_reasons.0, Ordering::Release);
4292 #[cfg(feature = "t1000e")]
4293 let t1000e_external_power = umsh_bsp_t1000e::power::usb_power_present();
4294 #[cfg(feature = "t1000e")]
4295 let t1000e_gpregret_state = umsh_bsp_t1000e::preferences::load_retained();
4296 #[cfg(feature = "t1000e")]
4297 let t1000e_retained_critical =
4298 t1000e_gpregret_state.is_some_and(|preferences| preferences.battery_critical);
4299 #[cfg(feature = "t1000e")]
4300 let mut t1000e_retained_state = mapped_ux_preferences().unwrap_or_default();
4301 #[cfg(feature = "t1000e")]
4302 {
4303 t1000e_retained_state.battery_critical = t1000e_retained_critical;
4304 umsh_bsp_t1000e::preferences::store(t1000e_retained_state);
4305 }
4306 #[cfg(feature = "t1000e")]
4307 if t1000e_retained_state.battery_critical && !t1000e_external_power {
4308 umsh_bsp_t1000e::shutdown::resume_persisted_sleep().await;
4309 }
4310 #[cfg(feature = "t1000e")]
4311 if t1000e_retained_state.battery_critical && t1000e_external_power {
4312 umsh_bsp_t1000e::preferences::set_battery_critical(false);
4313 }
4314 // RESETREAS.OFF alone proves a button wake: P0.06 is the only GPIO
4315 // DETECT source armed at System OFF entry (USB insertion wakes via
4316 // the native VBUS detector and sets its own reason bit). The pin
4317 // itself cannot be sampled this early — PIN_CNF resets to
4318 // input-disconnected, so the IN register reads 0 regardless of the
4319 // physical level.
4320 #[cfg(feature = "t1000e")]
4321 let t1000e_wake_requested = hardware_reset_reasons.off()
4322 || (hardware_reset_reasons.sreq()
4323 && t1000e_gpregret_state.is_some_and(|preferences| !preferences.asleep));
4324 #[cfg(feature = "t1000e")]
4325 let t1000e_wake_cleared_sleep = t1000e_wake_requested && t1000e_retained_state.asleep;
4326 #[cfg(feature = "t1000e")]
4327 if t1000e_wake_requested {
4328 umsh_bsp_t1000e::preferences::set_asleep(false);
4329 } else if umsh_bsp_t1000e::preferences::load().asleep {
4330 if t1000e_external_power {
4331 let mut led_config = SimpleConfig::default();
4332 led_config.prescaler = Prescaler::Div16;
4333 let led_pwm = SimplePwm::new_1ch(p.PWM1, p.P0_24, &led_config);
4334 let sleep_button = Input::new(p.P0_06, Pull::Down);
4335 let sleep_external_power = Input::new(p.P0_05, Pull::Down);
4336 let sleep_charge_active = Input::new(p.P1_03, Pull::Up);
4337 umsh_bsp_t1000e::shutdown::run_charging_sleep(
4338 led_pwm,
4339 sleep_button,
4340 sleep_external_power,
4341 sleep_charge_active,
4342 )
4343 .await;
4344 } else {
4345 umsh_bsp_t1000e::shutdown::resume_persisted_sleep().await;
4346 }
4347 }
4348
4349 // Disarm POWER USB interrupt state inherited across the DFU
4350 // handoff. The bootloader's USB stack enables the POWER
4351 // USBDETECTED/USBREMOVED/USBPWRRDY interrupts, and POWER lives
4352 // in the always-on domain, so the enables survive the DFU
4353 // activation reset. MPSL later owns the shared CLOCK_POWER
4354 // vector but services only CLOCK events, so with VBUS present a
4355 // pending USB power event re-enters the handler forever,
4356 // starving thread mode until the watchdog fires — the post-DFU
4357 // first-boot freeze. This firmware never uses these interrupts
4358 // (USB runs on SoftwareVbusDetect precisely because MPSL owns
4359 // POWER), so clear the enables and any pending events before
4360 // MPSL takes the vector.
4361 pac::POWER.intenclr().write(|w| {
4362 w.set_usbdetected(true);
4363 w.set_usbremoved(true);
4364 w.set_usbpwrrdy(true);
4365 });
4366 pac::POWER.events_usbdetected().write_value(0);
4367 pac::POWER.events_usbremoved().write_value(0);
4368 pac::POWER.events_usbpwrrdy().write_value(0);
4369
4370 // TEMPORARY freeze diagnostics: format the previous boot's
4371 // watchdog capture and PC-sample ring for the ASCII dump on the
4372 // first USB connect.
4373 let wdt_report: Option<&'static str> = (wdt_capture.is_some()
4374 || (pc_ring_count > 0 && hardware_reset_reasons.dog()))
4375 .then(|| {
4376 use core::fmt::Write as _;
4377 static REPORT: StaticCell<heapless09::String<2048>> = StaticCell::new();
4378 let report = REPORT.init(heapless09::String::new());
4379 let _ = write!(report, "\r\n=== WDT CAPTURE (previous boot) ===\r\n");
4380 if let Some(capture) = wdt_capture {
4381 PREV_WDT_PC.store(capture.pc, Ordering::Release);
4382 PREV_WDT_LR.store(capture.lr, Ordering::Release);
4383 PREV_WDT_PSR.store(capture.xpsr, Ordering::Release);
4384 let _ = write!(
4385 report,
4386 "pc={:#010x} lr={:#010x} psr={:#010x} exc={:#010x} sp={:#010x}\r\n",
4387 capture.pc, capture.lr, capture.xpsr, capture.exc_return, capture.sp,
4388 );
4389 let _ = write!(
4390 report,
4391 "CLOCK hfstat={:#x} lfstat={:#x} inten={:#x} evhf={} evlf={} evdone={} evctto={} lfsrc={:#x}\r\n",
4392 capture.clock[0],
4393 capture.clock[1],
4394 capture.clock[2],
4395 capture.clock[3],
4396 capture.clock[4],
4397 capture.clock[5],
4398 capture.clock[6],
4399 capture.clock[7],
4400 );
4401 let _ = write!(report, "stack above frame:\r\n");
4402 for row in capture.stack.chunks(4) {
4403 for word in row {
4404 let _ = write!(report, "{word:#010x} ");
4405 }
4406 let _ = write!(report, "\r\n");
4407 }
4408 } else {
4409 let _ = write!(report, "no exception-frame capture (WDT IRQ shielded)\r\n");
4410 }
4411 let _ = write!(report, "pc ring ({pc_ring_count} samples, oldest first):\r\n");
4412 for row in pc_ring[..pc_ring_count].chunks(4) {
4413 for word in row {
4414 let _ = write!(report, "{word:#010x} ");
4415 }
4416 let _ = write!(report, "\r\n");
4417 }
4418 let _ = write!(report, "=== END WDT CAPTURE ===\r\n");
4419 report.as_str()
4420 });
4421 #[cfg(feature = "ble-debug")]
4422 {
4423 set_security_trace_handler(Some(trouble_security_trace));
4424 set_security_diagnostic_trace_handler(Some(trouble_security_diagnostic_trace));
4425 set_connection_trace_handler(Some(trouble_connection_trace));
4426 }
4427
4428 // Board power (schematic): SX1262 = PWR_EN (P0.13); VDD_POWR =
4429 // PWR_EN ∧ (PWR_ON (P0.12) ∨ VBUS). Both must be high before the
4430 // LoRa module is addressed. PWR_EN floating happens to work — its
4431 // reset state leaks enough to run the board, which is exactly how
4432 // the half-collapsed off-state rail went unnoticed — but the
4433 // radio's supply gate deserves a driven level, not a lucky float.
4434 // Ownership of both transfers to shutdown_task.
4435 #[cfg(feature = "system-off-techo")]
4436 let peripheral_power = Output::new(p.P0_12, Level::High, OutputDrive::Standard);
4437 #[cfg(feature = "system-off-techo")]
4438 let power_enable = Output::new(p.P0_13, Level::High, OutputDrive::Standard);
4439
4440 // On T-1000E, seize LR1110 reset before any lengthy initialization.
4441 // The user button is active-high. Holding it through power-on is the
4442 // BLE spec's physical-presence ceremony; it must not invoke the
4443 // bootloader. The runtime task suppresses this same press until release.
4444 #[cfg(feature = "t1000e")]
4445 let radio_rst = Output::new(p.P1_10, Level::Low, OutputDrive::Standard);
4446 #[cfg(feature = "t1000e")]
4447 let mut button = Input::new(p.P0_06, Pull::Down);
4448 #[cfg(feature = "t1000e")]
4449 cortex_m::asm::delay(640_000);
4450 // A short press is how a powered-off T-1000E is started normally, so
4451 // the initial HIGH level alone cannot distinguish force pairing. Only
4452 // a press still held after one second is the deliberate ceremony.
4453 #[cfg(feature = "t1000e")]
4454 let force_pairing_at_boot = if button.is_high() {
4455 match select(button.wait_for_low(), Timer::after_secs(1)).await {
4456 Either::First(()) => false,
4457 Either::Second(()) => button.is_high(),
4458 }
4459 } else {
4460 false
4461 };
4462 #[cfg(feature = "t1000e")]
4463 FORCE_PAIRING_AT_BOOT.store(force_pairing_at_boot, Ordering::Release);
4464
4465 // SenseCAP Solar: the same physical-presence ceremony, carried by the
4466 // secondary user button — enclosure "USR", P1.07, active-low.
4467 //
4468 // It cannot live on the power button (enclosure "PWR", P1.01): any
4469 // press of PWR while the node is in System OFF enters the stock
4470 // bootloader's DFU mode unconditionally — duration is irrelevant, a
4471 // bare tap does it — so that press never reaches this code. Escaping
4472 // that needs a different bootloader. The same fact makes USR the only
4473 // button that actually powers the node back on, which is what makes it
4474 // the natural carrier for a hold-through-power-on gesture.
4475 //
4476 // A wake press is how a powered-off node is started, so the level at
4477 // t=0 cannot distinguish the ceremony from an ordinary power-on — only
4478 // a press still held after one second is deliberate. The button is
4479 // claimed here rather than later because FORCE_PAIRING_AT_BOOT must be
4480 // set before the BLE store seeds PAIRING_MODE.
4481 #[cfg(feature = "power-button")]
4482 let mut usr_button = Input::new(p.P1_07, Pull::Up);
4483 #[cfg(feature = "power-button")]
4484 let mut pwr_led = Output::new(p.P0_15, Level::Low, OutputDrive::Standard);
4485 #[cfg(feature = "power-button")]
4486 cortex_m::asm::delay(640_000);
4487 #[cfg(feature = "power-button")]
4488 {
4489 let force_pairing_at_boot = if usr_button.is_low() {
4490 match select(usr_button.wait_for_high(), Timer::after_secs(1)).await {
4491 Either::First(()) => false,
4492 Either::Second(()) => usr_button.is_low(),
4493 }
4494 } else {
4495 false
4496 };
4497 FORCE_PAIRING_AT_BOOT.store(force_pairing_at_boot, Ordering::Release);
4498 // Acknowledge the accepted ceremony on LED_A (white, active-high)
4499 // the instant the threshold is crossed, while the user is still
4500 // holding. Without this the only feedback is the LED_B pairing
4501 // blink, which is indistinguishable from an unbonded node's — so a
4502 // gesture that silently missed looked identical to one that
4503 // worked. Two blinks, deliberately distinct from the three that
4504 // acknowledge hold-to-power-off. Runs before the WDT is armed.
4505 if force_pairing_at_boot {
4506 for _ in 0..2 {
4507 pwr_led.set_high();
4508 Timer::after_millis(120).await;
4509 pwr_led.set_low();
4510 Timer::after_millis(120).await;
4511 }
4512 }
4513 }
4514
4515 // WDT: 8 s timeout, petted by the heartbeat task every ~2 s.
4516 let mut wdt_config = WdtConfig::default();
4517 wdt_config.timeout_ticks = 32768 * 8;
4518 let (mut wdt, [wdt_handle]) =
4519 Watchdog::try_new::<_, 1>(p.WDT, wdt_config).unwrap_or_else(|_| panic!("wdt"));
4520 // Freeze diagnostics: the TIMEOUT interrupt fires ~61 µs before
4521 // the watchdog reset; its handler (panic.rs `WDT`) records the
4522 // interrupted context's stacked PC/LR/xPSR into retained RAM.
4523 // Highest priority so it can preempt a storming lower-priority
4524 // handler; it runs only in the doomed final microseconds.
4525 wdt.enable_interrupt();
4526 unsafe {
4527 let mut peripherals = cortex_m::Peripherals::steal();
4528 peripherals
4529 .NVIC
4530 .set_priority(embassy_nrf::pac::Interrupt::WDT, 0);
4531 cortex_m::peripheral::NVIC::unmask(embassy_nrf::pac::Interrupt::WDT);
4532 }
4533 // Freeze diagnostics: 1 kHz PC sampler on TIMER2 (free on both
4534 // boards; MPSL owns TIMER0 only). Priority 1 — above the
4535 // thread-mode executor and MPSL's low-priority signal
4536 // processing, below MPSL's radio-critical priority 0.
4537 {
4538 let timer = pac::TIMER2;
4539 timer
4540 .mode()
4541 .write(|w| w.set_mode(pac::timer::vals::Mode::Timer));
4542 timer
4543 .bitmode()
4544 .write(|w| w.set_bitmode(pac::timer::vals::Bitmode::_32bit));
4545 timer.prescaler().write(|w| w.set_prescaler(4)); // 16 MHz / 2^4 = 1 MHz
4546 timer.cc(0).write_value(1_000); // 1 kHz sampling
4547 timer.shorts().write(|w| w.set_compare_clear(0, true));
4548 timer.intenset().write(|w| w.set_compare(0, true));
4549 timer.tasks_start().write_value(1);
4550 unsafe {
4551 let mut peripherals = cortex_m::Peripherals::steal();
4552 peripherals
4553 .NVIC
4554 .set_priority(embassy_nrf::pac::Interrupt::TIMER2, 1 << 5);
4555 cortex_m::peripheral::NVIC::unmask(embassy_nrf::pac::Interrupt::TIMER2);
4556 }
4557 }
4558 #[cfg(feature = "board-techo")]
4559 let led = Output::new(p.P0_14, Level::High, OutputDrive::Standard);
4560 // SenseCAP Solar: LED_B (P0.19, blue, active-high) is the heartbeat.
4561 #[cfg(feature = "board-sensecap-solar")]
4562 let led = Output::new(p.P0_19, Level::Low, OutputDrive::Standard);
4563 // Wio Tracker L1: the user LED (D11 / P1.01) is active-high.
4564 #[cfg(feature = "board-wio-tracker-l1")]
4565 let led = Output::new(p.P1_01, Level::Low, OutputDrive::Standard);
4566 // XIAO nRF52840: blue segment of the common-anode RGB LED (P0.06),
4567 // **active-low** — Level::High is off. Blue is the status colour
4568 // here (MeshCore's choice on this board); red stays free as a TX
4569 // indicator and green is the 10 kΩ leg, noticeably dimmer.
4570 #[cfg(feature = "board-xiao-nrf52")]
4571 let led = Output::new(p.P0_06, Level::High, OutputDrive::Standard);
4572 #[cfg(feature = "t1000e")]
4573 let led = {
4574 let mut config = SimpleConfig::default();
4575 config.prescaler = Prescaler::Div16;
4576 SimplePwm::new_1ch(p.PWM1, p.P0_24, &config)
4577 };
4578 // Service the watchdog throughout radio, persistence, MPSL, and BLE
4579 // initialization. Deferring this task until the final steady-state
4580 // join caused first-boot persistence to consume the entire watchdog
4581 // window and reset once before the device became usable.
4582 spawner.spawn(heartbeat(led, wdt_handle).unwrap());
4583 super::panic::breadcrumb_mark(3);
4584
4585 // A message in the panic slot means the last reset was a crash;
4586 // report that as the reset reason and keep the message text for
4587 // the first-USB-reader dump. The slot is cleared either way.
4588 let mut panic_report: Option<&'static str> = None;
4589 let boot_reason = {
4590 let mut slot = PanicSlot::new(super::panic::panic_region());
4591 if let Some(message) = slot.read() {
4592 use core::fmt::Write as _;
4593 static PANIC_REPORT: StaticCell<heapless09::String<1100>> = StaticCell::new();
4594 let text = PANIC_REPORT.init(heapless09::String::new());
4595 let _ = write!(text, "\r\n=== PANIC (previous boot) ===\r\n");
4596 for byte in message.iter().take(1000) {
4597 let c = *byte as char;
4598 let _ = text.push(if c.is_ascii_graphic() || c == ' ' {
4599 c
4600 } else {
4601 '.'
4602 });
4603 }
4604 let _ = write!(text, "\r\n=== END PANIC ===\r\n");
4605 panic_report = Some(text.as_str());
4606 slot.clear();
4607 Status::RESET_CRASH
4608 } else if hardware_reset_reasons.dog() {
4609 Status::RESET_WATCHDOG
4610 } else if hardware_reset_reasons.lockup() {
4611 Status::RESET_CRASH
4612 } else if hardware_reset_reasons.resetpin() {
4613 Status::RESET_EXTERNAL
4614 } else if hardware_reset_reasons.sreq() {
4615 Status::RESET_SOFTWARE
4616 } else {
4617 Status::RESET_POWER_ON
4618 }
4619 };
4620
4621 // ── SX1262 LoRa radio ────────────────────────────────────────────────
4622 // Pin assignment (T-Echo hardware, firmware-confirmed):
4623 // SPI bus: SCK=P0.19, MOSI=P0.22, MISO=P0.23 (TWISPI1)
4624 // CS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20
4625 // DIO2: internal RF switch; DIO3: 1.8 V TCXO.
4626 #[cfg(feature = "board-techo")]
4627 {
4628 let mut cfg = SpimConfig::default();
4629 // SX1262 datasheet §8.2: max SCK = 16 MHz, Mode 0.
4630 cfg.frequency = Frequency::M16;
4631 let radio_bus = Spim::new(
4632 p.TWISPI1, Irqs, p.P0_19, // SCK
4633 p.P0_23, // MISO
4634 p.P0_22, // MOSI
4635 cfg,
4636 );
4637 let radio_cs = Output::new(p.P0_24, Level::High, OutputDrive::Standard);
4638 let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
4639
4640 let radio_rst = Output::new(p.P0_25, Level::High, OutputDrive::Standard);
4641 let radio_dio1 = Input::new(p.P0_20, Pull::None);
4642 let radio_busy = Input::new(p.P0_17, Pull::None);
4643
4644 let iv = GenericSx126xInterfaceVariant::new(
4645 radio_rst, radio_dio1, radio_busy,
4646 None, // rf_switch_rx: DIO2 wired internally on the T-Echo module
4647 None, // rf_switch_tx: same
4648 )
4649 .unwrap();
4650
4651 let lora_config = LoraConfig {
4652 chip: Sx1262,
4653 tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V8), // DIO3 → 1.8 V TCXO
4654 use_dcdc: true,
4655 rx_boost: true,
4656 };
4657
4658 // enable_public_network=false → sync word 0x1424 (private).
4659 // session_config().sync_word must match this choice.
4660 // Radio init failure degrades to a USB/BLE-only device (RF dead)
4661 // rather than a startup panic → reboot loop: a display-less
4662 // field node must stay reachable to diagnose.
4663 match LoRa::new(Sx126x::new(radio_spi, iv, lora_config), false, Delay).await {
4664 Ok(lora) => {
4665 spawner.spawn(radio_task(lora).unwrap());
4666 }
4667 Err(error) => debug_log(format_args!("radio init FAILED (RF disabled): {error:?}")),
4668 }
4669 }
4670
4671 // ── LR1110 LoRa radio (T-1000E) ─────────────────────────────────────
4672 #[cfg(feature = "t1000e")]
4673 {
4674 let mut cfg = SpimConfig::default();
4675 cfg.frequency = Frequency::M8;
4676 let radio_bus = Spim::new(p.TWISPI0, Irqs, p.P0_11, p.P1_08, p.P1_09, cfg);
4677 let radio_cs = Output::new(p.P0_12, Level::High, OutputDrive::Standard);
4678 let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
4679 let radio_interrupt = Input::new(p.P1_01, Pull::Down);
4680 let radio_busy = Input::new(p.P0_07, Pull::None);
4681 let iv = GenericLr1110InterfaceVariant::new(
4682 radio_rst,
4683 radio_interrupt,
4684 radio_busy,
4685 None,
4686 None,
4687 )
4688 .unwrap_or_else(|_| panic!("lr1110 iv"));
4689 let lora_config = LoraConfig {
4690 chip: Lr1110Chip::with_pa(PaSelection::Hp),
4691 tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V6),
4692 use_dcdc: false,
4693 rx_boost: true,
4694 rf_switch: Some(RF_SWITCH),
4695 };
4696 match LoRa::new(Lr1110::new(radio_spi, iv, lora_config), false, Delay).await {
4697 Ok(lora) => {
4698 spawner.spawn(radio_task(lora).unwrap());
4699 }
4700 Err(error) => debug_log(format_args!("radio init FAILED (RF disabled): {error:?}")),
4701 }
4702 }
4703
4704 // ── SX1262 LoRa radio (SenseCAP Solar Node, XIAO nRF52840 kit) ──────
4705 // Byte-for-byte the Wio Tracker L1 SX1262 bring-up on this pin map
4706 // (external RXEN, DIO2 internal RF switch, DIO3 1.8 V TCXO):
4707 // SPI TWISPI1 @16MHz: SCK=P1.13, MISO=P1.14, MOSI=P1.15, CS=P0.04
4708 // RST=P0.28, BUSY=P0.29, DIO1=P0.03, RXEN=P0.05 (rf_switch_rx)
4709 //
4710 // Two boards share this block verbatim, and not by coincidence:
4711 // both are XIAO-pinout carriers around the same Wio SX1262 module,
4712 // so the wiring is identical pin for pin. The XIAO kit's copy is
4713 // additionally schematic-confirmed (Wio-SX1262 for XIAO V1.0)
4714 // rather than reconstructed from vendor firmware.
4715 //
4716 // Two carrier details worth knowing here, both from that schematic:
4717 // RESET has a 10 kΩ pull-up, so a floating pin does *not* hold the
4718 // radio down — the explicit reset below is what does. RXEN has no
4719 // pull at all, which is why it is clamped at construction rather
4720 // than left to lora-phy's first transition.
4721 #[cfg(any(feature = "board-sensecap-solar", feature = "board-xiao-nrf52"))]
4722 {
4723 let mut cfg = SpimConfig::default();
4724 cfg.frequency = Frequency::M16;
4725 let radio_bus = Spim::new(
4726 p.TWISPI1, Irqs, p.P1_13, // SCK
4727 p.P1_14, // MISO
4728 p.P1_15, // MOSI
4729 cfg,
4730 );
4731 let radio_cs = Output::new(p.P0_04, Level::High, OutputDrive::Standard);
4732 let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
4733
4734 let radio_rst = Output::new(p.P0_28, Level::High, OutputDrive::Standard);
4735 let radio_dio1 = Input::new(p.P0_03, Pull::None);
4736 let radio_busy = Input::new(p.P0_29, Pull::None);
4737 // RXEN clamped low at construction (safety contract) until
4738 // lora-phy drives it HIGH in RX / LOW in TX.
4739 let radio_rxen = Output::new(p.P0_05, Level::Low, OutputDrive::Standard);
4740
4741 let iv = GenericSx126xInterfaceVariant::new(
4742 radio_rst,
4743 radio_dio1,
4744 radio_busy,
4745 Some(radio_rxen), // rf_switch_rx
4746 None, // rf_switch_tx: none
4747 )
4748 .unwrap();
4749
4750 let lora_config = LoraConfig {
4751 chip: Sx1262,
4752 tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V8), // DIO3 → 1.8 V TCXO
4753 use_dcdc: true,
4754 rx_boost: true,
4755 };
4756
4757 // Radio init failure degrades to a USB/BLE-only device (RF dead)
4758 // rather than a startup panic → reboot loop. The SX1262 bring-up
4759 // on these pins is hardware-proven on the SenseCAP Solar
4760 // (bidirectional RF, 2026-07-23); on the XIAO kit it is still
4761 // only schematic-confirmed, which is exactly the case this
4762 // degrade-instead-of-panic path exists for.
4763 match LoRa::new(Sx126x::new(radio_spi, iv, lora_config), false, Delay).await {
4764 Ok(lora) => {
4765 spawner.spawn(radio_task(lora).unwrap());
4766 }
4767 Err(error) => debug_log(format_args!("radio init FAILED (RF disabled): {error:?}")),
4768 }
4769 }
4770
4771 // ── SX1262 LoRa radio (Wio Tracker L1) ──────────────────────────────
4772 // The board the Solar P1 block above was itself ported from
4773 // (external RXEN, DIO2 internal RF switch, DIO3 1.8 V TCXO):
4774 // SPI TWISPI1 @16MHz: SCK=P0.30, MISO=P0.03, MOSI=P0.28, CS=P1.14
4775 // RST=P1.07, BUSY=P1.10, DIO1=P0.07, RXEN=P1.08 (rf_switch_rx)
4776 #[cfg(feature = "board-wio-tracker-l1")]
4777 {
4778 let mut cfg = SpimConfig::default();
4779 cfg.frequency = Frequency::M16;
4780 let radio_bus = Spim::new(
4781 p.TWISPI1, Irqs, p.P0_30, // SCK
4782 p.P0_03, // MISO
4783 p.P0_28, // MOSI
4784 cfg,
4785 );
4786 let radio_cs = Output::new(p.P1_14, Level::High, OutputDrive::Standard);
4787 let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
4788
4789 let radio_rst = Output::new(p.P1_07, Level::High, OutputDrive::Standard);
4790 let radio_dio1 = Input::new(p.P0_07, Pull::None);
4791 let radio_busy = Input::new(p.P1_10, Pull::None);
4792 // RXEN clamped low at construction (safety contract). Holding
4793 // the external LNA biased through a +22 dBm transmit is the
4794 // one way firmware can damage this board.
4795 let radio_rxen = Output::new(p.P1_08, Level::Low, OutputDrive::Standard);
4796
4797 let iv = GenericSx126xInterfaceVariant::new(
4798 radio_rst,
4799 radio_dio1,
4800 radio_busy,
4801 Some(radio_rxen), // rf_switch_rx
4802 None, // rf_switch_tx: no separate TX enable pin
4803 )
4804 .unwrap();
4805
4806 let lora_config = LoraConfig {
4807 chip: Sx1262,
4808 tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V8), // DIO3 → 1.8 V TCXO
4809 use_dcdc: true,
4810 rx_boost: true,
4811 };
4812
4813 // Radio init failure degrades to a USB/BLE-only device (RF dead)
4814 // rather than a startup panic → reboot loop.
4815 match LoRa::new(Sx126x::new(radio_spi, iv, lora_config), false, Delay).await {
4816 Ok(lora) => {
4817 spawner.spawn(radio_task(lora).unwrap());
4818 }
4819 Err(error) => debug_log(format_args!("radio init FAILED (RF disabled): {error:?}")),
4820 }
4821 }
4822
4823 // The mux is the radio runner's only client; the session (and,
4824 // later, the device node) transmit and receive through their
4825 // virtual bundles.
4826 spawner.spawn(radio_mux_task().unwrap());
4827
4828 super::panic::breadcrumb_mark(4);
4829
4830 // Device-node counter state exists in every image; the BLE
4831 // branch below attaches its journal once the shared flash is
4832 // up. Must precede device_task/bring_up, which hold references.
4833 let node_counters = init_node_counters();
4834
4835 // ── MPSL + Nordic SoftDevice Controller ────────────────────────────
4836 // MPSL owns CLOCK/POWER, RADIO, RTC0, TIMER0, TEMP, and the listed
4837 // PPI channels. embassy-time remains on RTC1; LoRa remains on SPIM1.
4838 //
4839 // MPSL, the flash driver, and the protocol journals come up in
4840 // BOTH images: the flash driver needs only the MPSL timeslot
4841 // scheduler, not the BLE controller on top of it. The `no-ble`
4842 // diagnostic build skips just the SDC/Trouble construction
4843 // below; it is then a USB-only device with identical persistence
4844 // and clock configuration.
4845 let mut rng = rng::Rng::new(p.RNG, Irqs);
4846 // Everything this RNG feeds is key material — the device identity
4847 // secret, the CSPRNG seeds, the BLE local IRK — so take the
4848 // bias-corrected output. The nRF52840 TRNG is measurably biased
4849 // towards one bit value without it; correction costs roughly
4850 // 120 µs a byte instead of 40, which at a few 32-byte draws per
4851 // boot is not worth trading entropy quality for.
4852 rng.set_bias_correction(true);
4853 #[cfg(not(feature = "no-ble"))]
4854 let mut sdc_memory = sdc::Mem::<8192>::new();
4855 let mpsl = {
4856 let mpsl_peripherals = mpsl::Peripherals::new(
4857 p.RTC0, p.TIMER0, p.TEMP, p.PPI_CH19, p.PPI_CH30, p.PPI_CH31,
4858 );
4859 // Boards with a 32.768 kHz crystal use it (20 ppm); crystal-less
4860 // boards (XIAO-based SenseCAP Solar) run MPSL's LFCLK from the
4861 // internal RC oscillator, periodically calibrated against the HF
4862 // clock (rc_ctiv = 16 → every 4 s; rc_temp_ctiv = 2 → also on
4863 // ~0.5 °C drift). Must match the embassy `lfclk_source` above.
4864 #[cfg(not(feature = "lfclk-rc"))]
4865 let lfclk = mpsl::raw::mpsl_clock_lfclk_cfg_t {
4866 source: mpsl::raw::MPSL_CLOCK_LF_SRC_XTAL as u8,
4867 rc_ctiv: 0,
4868 rc_temp_ctiv: 0,
4869 accuracy_ppm: 20,
4870 skip_wait_lfclk_started: false,
4871 };
4872 #[cfg(feature = "lfclk-rc")]
4873 let lfclk = mpsl::raw::mpsl_clock_lfclk_cfg_t {
4874 source: mpsl::raw::MPSL_CLOCK_LF_SRC_RC as u8,
4875 rc_ctiv: 16,
4876 rc_temp_ctiv: 2,
4877 accuracy_ppm: 250,
4878 skip_wait_lfclk_started: false,
4879 };
4880 static MPSL: StaticCell<MultiprotocolServiceLayer> = StaticCell::new();
4881 static TIMESLOT_MEM: StaticCell<mpsl::SessionMem<1>> = StaticCell::new();
4882 let mpsl: &'static MultiprotocolServiceLayer = MPSL.init(
4883 MultiprotocolServiceLayer::with_timeslots(
4884 mpsl_peripherals,
4885 Irqs,
4886 lfclk,
4887 TIMESLOT_MEM.init(mpsl::SessionMem::new()),
4888 )
4889 .unwrap_or_else(|_| panic!("mpsl init")),
4890 );
4891 spawner.spawn(mpsl_task(mpsl).unwrap());
4892 mpsl
4893 };
4894 super::panic::breadcrumb_mark(5);
4895 static SHARED_FLASH: StaticCell<SharedFlash> = StaticCell::new();
4896 let flash = SHARED_FLASH.init(Mutex::new(JournalFlash(nrf_mpsl::Flash::take(
4897 mpsl, p.NVMC,
4898 ))));
4899 // Mount the protocol journals before the ULCP session starts: a
4900 // stored snapshot must be restored (and the PHY re-applied) and
4901 // the persisted device identity installed before the first host
4902 // command.
4903 let (proto_store, boot_snapshot) = ProtoStore::mount(flash, proto_store::PAGE0).await;
4904 let (mut identity_store, identity_payload) =
4905 ProtoStore::mount(flash, proto_store::IDENTITY_PAGE0).await;
4906 let (ux_store, _) = ProtoStore::mount(flash, proto_store::UX_PAGE0).await;
4907 mount_node_counters(node_counters, flash).await;
4908 #[cfg(feature = "t1000e")]
4909 let mut ux_store = ux_store;
4910 #[cfg(feature = "t1000e")]
4911 if t1000e_wake_cleared_sleep {
4912 let _ =
4913 persist_ux_preferences(&mut ux_store, umsh_bsp_t1000e::preferences::load()).await;
4914 }
4915 // Both halves of the persisted keypair: the public key seeds
4916 // the session's PROP_DEV_KEY surface, the secret brings up
4917 // the device node's MAC identity.
4918 //
4919 // A device identity always exists. When the journal is empty —
4920 // a factory-fresh board, or the boot that completes a factory
4921 // reset — one is generated here and persisted before anything
4922 // can observe its absence, so identity is never a commissioning
4923 // step the operator has to perform. Installing a *specific*
4924 // identity later (`PROP_DEV_PRIVATE_KEY`) stays available and is
4925 // recovery, not setup.
4926 //
4927 // The secret comes straight from the hardware TRNG, which
4928 // blocks until the peripheral has produced each byte and is
4929 // bias-corrected above. It is deliberately not drawn from the
4930 // ChaCha20 stream seeded below: that stream is fine, but there
4931 // is no reason to put a derivation between the noise source and
4932 // a key that outlives the device's configuration.
4933 let mut identity_keys = identity_payload
4934 .as_deref()
4935 .and_then(proto_store::decode_identity);
4936 if identity_keys.is_none() {
4937 let mut secret = [0u8; 32];
4938 rng.fill_bytes(&mut secret).await;
4939 let (public, record) = driver::device_identity_record(&secret);
4940 // A persist failure is not fatal: the device runs on this
4941 // key for the current boot and generates another next time.
4942 // Reporting it matters more than refusing to boot, because
4943 // the alternative is a radio that is silently inert.
4944 match identity_store.persist(&record).await {
4945 Ok(()) => debug_log(format_args!(
4946 "device identity generated at first boot key={}",
4947 umsh_core::PublicKey(public)
4948 )),
4949 Err(()) => debug_log(format_args!(
4950 "device identity generated but persist=FAILED — volatile this boot"
4951 )),
4952 }
4953 identity_keys = Some((secret, public));
4954 }
4955 let boot_identity_keys = identity_keys;
4956 // A replaced identity leaves its TX boundary behind in the
4957 // counter journal; drop it so the map cannot silt up.
4958 if let Some((_, public)) = boot_identity_keys.as_ref() {
4959 prune_stale_tx_counters(node_counters, public).await;
4960 }
4961 // Seed the identity-generation and device-node CSPRNGs from the
4962 // TRNG; in the BLE image this must happen while the peripheral
4963 // is still ours — build_sdc below hands the RNG to the
4964 // SoftDevice Controller for its lifetime.
4965 let mut identity_seed = [0u8; 32];
4966 rng.fill_bytes(&mut identity_seed).await;
4967 let identity_rng = <IdentityRng as rand_core::SeedableRng>::from_seed(identity_seed);
4968 let mut node_seed = [0u8; 32];
4969 rng.fill_bytes(&mut node_seed).await;
4970
4971 #[cfg(not(feature = "no-ble"))]
4972 let (controller, ble_store) = {
4973 let mut ble_store = BleStore::mount(flash).await;
4974 // Deliberate recovery image for hardware testing. This runs before the
4975 // Trouble host is constructed, so there is no live bond table to keep
4976 // in sync: the empty persisted snapshot becomes the host's initial
4977 // state below. Preserve the device's local IRK, matching the normal
4978 // security-wipe operation.
4979 #[cfg(feature = "ble-wipe-on-boot")]
4980 {
4981 debug_log(format_args!(
4982 "ONE-TIME BLE WIPE begin bonds={} pin={}",
4983 ble_store.snapshot().bonds.len(),
4984 ble_store.snapshot().pin.is_some(),
4985 ));
4986 ble_store
4987 .clear_security()
4988 .await
4989 .unwrap_or_else(|_| panic!("one-time ble wipe failed"));
4990 debug_log(format_args!(
4991 "ONE-TIME BLE WIPE complete bonds={} pin={}",
4992 ble_store.snapshot().bonds.len(),
4993 ble_store.snapshot().pin.is_some(),
4994 ));
4995 }
4996 BLE_BONDS_AT_BOOT.store(ble_store.snapshot().bonds.len() as u8, Ordering::Release);
4997 BLE_BOND_COUNT.store(ble_store.snapshot().bonds.len() as u8, Ordering::Release);
4998 // See the matching seed in `ble_app` for why
4999 // `boot-pairing-window` boards force this true every boot.
5000 PAIRING_MODE.store(
5001 ble_store.snapshot().bonds.is_empty()
5002 || FORCE_PAIRING_AT_BOOT.load(Ordering::Acquire)
5003 || cfg!(feature = "boot-pairing-window"),
5004 Ordering::Release,
5005 );
5006
5007 let sdc_peripherals = sdc::Peripherals::new(
5008 p.PPI_CH17, p.PPI_CH18, p.PPI_CH20, p.PPI_CH21, p.PPI_CH22, p.PPI_CH23, p.PPI_CH24,
5009 p.PPI_CH25, p.PPI_CH26, p.PPI_CH27, p.PPI_CH28, p.PPI_CH29,
5010 );
5011 if ble_store.snapshot().local_irk.is_none() {
5012 let mut local_irk = [0u8; 16];
5013 rng.fill_bytes(&mut local_irk).await;
5014 if local_irk == [0; 16] {
5015 local_irk[0] = 1;
5016 }
5017 ble_store
5018 .set_local_irk(local_irk)
5019 .await
5020 .unwrap_or_else(|_| panic!("local irk persist"));
5021 }
5022 #[cfg(feature = "ble-store-fault-inject")]
5023 {
5024 BLE_STORE_FAULT_ARMED.store(true, Ordering::Release);
5025 debug_log(format_args!(
5026 "STORE FAULT INJECTION ARMED: all runtime writes and erases will fail"
5027 ));
5028 }
5029 super::panic::breadcrumb_mark(6);
5030 let controller = build_sdc(sdc_peripherals, &mut rng, mpsl, &mut sdc_memory)
5031 .unwrap_or_else(|_| panic!("sdc init"));
5032 super::panic::breadcrumb_mark(7);
5033 (controller, ble_store)
5034 };
5035 // The session surfaces only the public key; the secret stays with
5036 // the device node.
5037 let boot_identity = boot_identity_keys.map(|(_secret, public)| public);
5038
5039 // ── USB stack ────────────────────────────────────────────────────────
5040 // HardwareVbusDetect cannot share POWER with MPSL. This tethered device
5041 // treats USB as present/ready; CDC connection state still supplies the
5042 // protocol attach/detach edges used by advertising arbitration.
5043 static VBUS: StaticCell<SoftwareVbusDetect> = StaticCell::new();
5044 let vbus = VBUS.init(SoftwareVbusDetect::new(true, true));
5045 let driver = Driver::new(p.USBD, Irqs, &*vbus);
5046
5047 let mut config = Config::new(0x16c0, 0x27dd);
5048 config.manufacturer = Some("UMSH");
5049 #[cfg(feature = "board-techo")]
5050 {
5051 config.product = Some("T-Echo UMSH Radio");
5052 config.serial_number = Some("techo");
5053 }
5054 #[cfg(feature = "t1000e")]
5055 {
5056 config.product = Some("T-1000E UMSH Radio");
5057 config.serial_number = Some("t1000e");
5058 }
5059 #[cfg(feature = "board-sensecap-solar")]
5060 {
5061 config.product = Some("Solar Node UMSH Radio");
5062 config.serial_number = Some("sensecap-solar");
5063 }
5064 #[cfg(feature = "board-wio-tracker-l1")]
5065 {
5066 config.product = Some("Wio Tracker UMSH Radio");
5067 config.serial_number = Some("wio-tracker-l1");
5068 }
5069 #[cfg(feature = "board-xiao-nrf52")]
5070 {
5071 config.product = Some("XIAO nRF52 UMSH Radio");
5072 config.serial_number = Some("xiao-nrf52");
5073 }
5074 config.max_power = 100;
5075 config.max_packet_size_0 = 64;
5076
5077 static CONFIG_DESC: StaticCell<[u8; 256]> = StaticCell::new();
5078 static BOS_DESC: StaticCell<[u8; 256]> = StaticCell::new();
5079 static MSOS_DESC: StaticCell<[u8; 0]> = StaticCell::new();
5080 static CONTROL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
5081 static STATE: StaticCell<State> = StaticCell::new();
5082
5083 let mut builder = Builder::new(
5084 driver,
5085 config,
5086 CONFIG_DESC.init([0; 256]),
5087 BOS_DESC.init([0; 256]),
5088 MSOS_DESC.init([0; 0]),
5089 CONTROL_BUF.init([0; 64]),
5090 );
5091
5092 let class = CdcAcmClass::new(&mut builder, STATE.init(State::new()), 64);
5093 let mut usb = builder.build();
5094
5095 let (tx, raw_rx, ctrl) = class.split_with_control();
5096 let rx = CdcAcmRescue::new(raw_rx, ctrl);
5097
5098 spawner.spawn(output_task(tx, wdt_report, panic_report).unwrap());
5099 spawner.spawn(usb_in_task(rx).unwrap());
5100 spawner.spawn(
5101 device_task(
5102 boot_reason,
5103 proto_store,
5104 boot_snapshot,
5105 identity_store,
5106 boot_identity,
5107 identity_rng,
5108 node_counters,
5109 )
5110 .unwrap(),
5111 );
5112
5113 // ── Device node ─────────────────────────────────────────────────────
5114 // The device identity always exists by this point, so the full
5115 // MAC/node stack always comes up on mux client B; whether it
5116 // transmits is a matter of configuration (the PHY enable state
5117 // and the forwarding switch), not of whether a key was ever
5118 // provisioned. The airtime hint is the worst case at the
5119 // MeshCore-US default profile — the MAC scheduler only uses it as
5120 // a conservative bound.
5121 //
5122 // The one exception is a crash reboot: skip one boot of the
5123 // device node so the surviving boot stays reachable and prints
5124 // the previous panic over USB.
5125 let (identity_secret, _public) = boot_identity_keys
5126 .as_ref()
5127 .expect("a device identity is generated at boot when none is stored");
5128 if panic_report.is_none() {
5129 let t_frame_ms = umsh_radio_loraphy::airtime_ms(
5130 lora_phy::mod_params::SpreadingFactor::_7,
5131 lora_phy::mod_params::Bandwidth::_62KHz,
5132 umsh_radio_loraphy::MAX_PAYLOAD,
5133 );
5134 super::device_node::bring_up(
5135 spawner,
5136 identity_secret,
5137 node_seed,
5138 t_frame_ms,
5139 node_counters,
5140 )
5141 .await;
5142 }
5143 super::panic::breadcrumb_mark(8);
5144
5145 // The touch button only asks for the e-paper backlight; a locate
5146 // alert can ask for it too, so the pin belongs to the arbiter
5147 // rather than to either caller. Menu input is exclusively the
5148 // side button below.
5149 #[cfg(feature = "board-techo")]
5150 {
5151 let touch = Input::new(p.P0_11, Pull::Up);
5152 let backlight = Output::new(p.P1_11, Level::Low, OutputDrive::Standard);
5153 spawner.spawn(touch_task(touch).unwrap());
5154 spawner.spawn(backlight_task(backlight).unwrap());
5155
5156 let mut display_config = SpimConfig::default();
5157 display_config.frequency = Frequency::M4;
5158 let display_spi = Spim::new(p.SPI2, Irqs, p.P0_31, p.P1_07, p.P0_29, display_config);
5159 let display_cs = Output::new(p.P0_30, Level::High, OutputDrive::Standard);
5160 let display_dc = Output::new(p.P0_28, Level::Low, OutputDrive::Standard);
5161 let display_reset = Output::new(p.P0_02, Level::High, OutputDrive::Standard);
5162 let display_busy = Input::new(p.P0_03, Pull::None);
5163 spawner.spawn(
5164 display_task(
5165 display_spi,
5166 display_cs,
5167 display_dc,
5168 display_reset,
5169 display_busy,
5170 )
5171 .unwrap(),
5172 );
5173
5174 let button = Input::new(p.P1_10, Pull::Up);
5175 spawner.spawn(button_task(button).unwrap());
5176 spawner.spawn(shutdown_task(peripheral_power, power_enable).unwrap());
5177
5178 // Quectel L76K on UARTE0. `BufferedUarte` rather than a plain
5179 // one because NMEA arrives as lines of unpredictable length:
5180 // a plain read would block until its buffer filled, holding a
5181 // complete sentence hostage to the start of the next one.
5182 //
5183 // TIMER1 and PPI 0/1 with group 0 are free — MPSL holds
5184 // TIMER0 and PPI 19/30/31, the softdevice controller holds
5185 // 17/18 and 20–29, and the freeze diagnostics hold TIMER2.
5186 let mut gnss_config = UarteConfig::default();
5187 gnss_config.baudrate = UarteBaudrate::Baud9600;
5188 static GNSS_RX: StaticCell<[u8; 256]> = StaticCell::new();
5189 static GNSS_TX: StaticCell<[u8; 16]> = StaticCell::new();
5190 let gnss_uart = BufferedUarte::new(
5191 p.UARTE0,
5192 p.TIMER1,
5193 p.PPI_CH0,
5194 p.PPI_CH1,
5195 p.PPI_GROUP0,
5196 // rxd, then txd. Measured, not taken from the variant
5197 // files: the module's TX — the line carrying NMEA — is
5198 // P1.09, the opposite of what the upstream pin names
5199 // suggest. See docs/hardware/lilygo-techo-hardware.md.
5200 p.P1_09,
5201 p.P1_08,
5202 Irqs,
5203 gnss_config,
5204 GNSS_RX.init([0; 256]),
5205 // Nothing is sent to this receiver: the L76K needs no
5206 // configuration to emit what UMSH reads. The buffer is
5207 // the smallest the driver will take.
5208 GNSS_TX.init([0; 16]),
5209 );
5210 spawner.spawn(gnss_task(gnss_uart, BoardGnss::new(p.P1_02, p.P1_05)).unwrap());
5211 }
5212
5213 #[cfg(feature = "t1000e")]
5214 {
5215 let mut buzzer_config = SimpleConfig::default();
5216 buzzer_config.prescaler = Prescaler::Div16;
5217 let buzzer_pwm = SimplePwm::new_1ch(p.PWM0, p.P0_25, &buzzer_config);
5218 let buzzer_enable = Output::new(p.P1_05, Level::Low, OutputDrive::Standard);
5219 let initial_preferences = umsh_bsp_t1000e::preferences::load();
5220 spawner.spawn(
5221 t1000e_buzzer_task(buzzer_pwm, buzzer_enable, initial_preferences.silent).unwrap(),
5222 );
5223 // The normal power-on chirp is intentional. Early startup already
5224 // forced both buzzer pins low, so this is the first and only sound.
5225 umsh_bsp_t1000e::BUZZER_SIGNAL.signal(&buzzer_melodies::POWER_ON);
5226
5227 let sensor_rail = Output::new(p.P1_06, Level::Low, OutputDrive::Standard);
5228 // The light sensor's own enable, downstream of the rail.
5229 let sensor_enable = Output::new(p.P0_04, Level::Low, OutputDrive::Standard);
5230 // AIN0 the battery divider, AIN5 the ambient light sensor. The
5231 // two are never wanted at the same instant and want opposite
5232 // converter configurations, so the BSP builds a single-channel
5233 // `Saadc` per measurement rather than scanning both.
5234 let external_power = Input::new(p.P0_05, Pull::Down);
5235 let charge_active = Input::new(p.P1_03, Pull::Up);
5236 spawner.spawn(
5237 t1000e_power_task(
5238 p.SAADC,
5239 p.P0_02,
5240 p.P0_29,
5241 sensor_rail,
5242 sensor_enable,
5243 external_power,
5244 charge_active,
5245 )
5246 .unwrap(),
5247 );
5248 spawner.spawn(t1000e_button_task(button, force_pairing_at_boot, ux_store).unwrap());
5249 spawner.spawn(t1000e_shutdown_task().unwrap());
5250
5251 // VRTC, main enable, sleep interrupt, reset, RTC interrupt,
5252 // and the stop line. All six matter: the receiver stays silent
5253 // if the last two are left floating.
5254 #[allow(unused_mut)]
5255 let mut gnss_control =
5256 BoardGnss::new(p.P0_08, p.P1_11, p.P1_12, p.P1_15, p.P0_15, p.P1_14);
5257
5258 // Airoha AG3335 on UARTE0, at 115200 rather than the L76K
5259 // boards' 9600. `BufferedUarte` rather than a plain one because
5260 // NMEA arrives as lines of unpredictable length: a plain read
5261 // would block until its buffer filled, holding a complete
5262 // sentence hostage to the start of the next one.
5263 //
5264 // TIMER1 and PPI 0/1 with group 0 are free — MPSL holds TIMER0
5265 // and PPI 19/30/31, the softdevice controller holds 17/18 and
5266 // 20–29, and the freeze diagnostics hold TIMER2.
5267 let mut gnss_config = UarteConfig::default();
5268 gnss_config.baudrate = UarteBaudrate::Baud115200;
5269 // Twice the L76K boards' buffer. The same sentences arrive
5270 // twelve times faster here, and an overrun costs a whole fix
5271 // cycle rather than a sentence.
5272 static GNSS_RX: StaticCell<[u8; 512]> = StaticCell::new();
5273 // Big enough for the whole wake command in one pass, so
5274 // enabling the optional sentences is not several round trips.
5275 static GNSS_TX: StaticCell<[u8; 64]> = StaticCell::new();
5276 let gnss_uart = BufferedUarte::new(
5277 p.UARTE0,
5278 p.TIMER1,
5279 p.PPI_CH0,
5280 p.PPI_CH1,
5281 p.PPI_GROUP0,
5282 // rxd, then txd. Unlike the T-Echo, the upstream names here
5283 // agree with the electrical direction: `GPS_RX_PIN` is the
5284 // MCU's RX, and P0.14 carries NMEA. That is the same
5285 // reading of `GPS_RX_PIN` that turned out to be correct on
5286 // the T-Echo once its contradictory `PIN_SERIAL1_*` names
5287 // were discarded. See docs/hardware/t1000e-hardware.md.
5288 p.P0_14,
5289 p.P0_13,
5290 Irqs,
5291 gnss_config,
5292 GNSS_RX.init([0; 512]),
5293 // Used: the AG3335 persists its NMEA output selection, so
5294 // the BSP re-enables GSA and GSV on every wake. See
5295 // `umsh_bsp_t1000e::gnss`.
5296 GNSS_TX.init([0; 64]),
5297 );
5298 spawner.spawn(gnss_task(gnss_uart, gnss_control).unwrap());
5299 }
5300
5301 // SenseCAP Solar battery monitor: SAADC on AIN7/P0.31, resistor
5302 // divider gated by P0.14 (active-low). Mirrors the T-1000E SAADC
5303 // configuration (12-bit, GAIN1_6, 0.6 V ref) so the BSP conversion
5304 // constant is comparable. No charge-detect / external-power GPIO
5305 // (the CN3165 exposes none); VBUS presence comes from usbregstatus.
5306 #[cfg(feature = "board-sensecap-solar")]
5307 {
5308 let saadc = Saadc::new(
5309 p.SAADC,
5310 Irqs,
5311 SaadcConfig::default(),
5312 [ChannelConfig::single_ended(p.P0_31)],
5313 );
5314 let divider_gate = Output::new(p.P0_14, Level::High, OutputDrive::Standard);
5315 spawner.spawn(sensecap_power_task(saadc, divider_gate).unwrap());
5316
5317 // Quectel L76K on UARTE0, behind the one enable in this family
5318 // that really cuts the module's power — which on a solar node
5319 // is the whole point. `BufferedUarte` rather than a plain one
5320 // because NMEA arrives as lines of unpredictable length: a
5321 // plain read would block until its buffer filled, holding a
5322 // complete sentence hostage to the start of the next one.
5323 //
5324 // TIMER1 and PPI 0/1 with group 0 are free — MPSL holds
5325 // TIMER0 and PPI 19/30/31, the softdevice controller holds
5326 // 17/18 and 20–29, and the freeze diagnostics hold TIMER2.
5327 #[cfg(feature = "cap-gnss")]
5328 {
5329 let mut gnss_config = UarteConfig::default();
5330 gnss_config.baudrate = UarteBaudrate::Baud9600;
5331 static GNSS_RX: StaticCell<[u8; 256]> = StaticCell::new();
5332 static GNSS_TX: StaticCell<[u8; 16]> = StaticCell::new();
5333 let gnss_uart = BufferedUarte::new(
5334 p.UARTE0,
5335 p.TIMER1,
5336 p.PPI_CH0,
5337 p.PPI_CH1,
5338 p.PPI_GROUP0,
5339 // rxd, then txd. The `GPS_TX_PIN` / `GPS_RX_PIN` names
5340 // on this board are the same trap as everywhere else in
5341 // the family; measured, the module's output is P1.12 —
5342 // the family rule that `GPS_RX_PIN` is the MCU's RX.
5343 // See docs/hardware/sensecap-solar-node-p1-pro-hardware.md.
5344 p.P1_12,
5345 p.P1_11,
5346 Irqs,
5347 gnss_config,
5348 GNSS_RX.init([0; 256]),
5349 // Nothing is sent to this receiver: the L76K needs no
5350 // configuration to emit what UMSH reads. The buffer is
5351 // the smallest the driver will take.
5352 GNSS_TX.init([0; 16]),
5353 );
5354 spawner.spawn(gnss_task(gnss_uart, BoardGnss::new(p.P1_05, p.P0_02)).unwrap());
5355 }
5356 }
5357
5358 // XIAO nRF52840 kit peripherals. Same SAADC channel and the same
5359 // physical 1M/510k network as the SenseCAP Solar above, but the
5360 // low side is *not* a gate: P0.14 is created LOW and stays LOW
5361 // forever, because driving it high sits P0.31 exactly at its
5362 // VDD+0.3 absolute maximum and releasing it takes P0.31 to the
5363 // full cell voltage. Seeed's own wiki documents the rule; the
5364 // shipping Meshtastic build for this board violates it.
5365 //
5366 // The BQ25100 adds what the other boards here lack: HICHG (P0.13,
5367 // LOW = 100 mA) and ~CHG (P0.17, open-drain, LOW while charging).
5368 // ~CHG shares its node with the red charge LED, so it is an input
5369 // and nothing else. Both pins are handed to the monitor so they
5370 // stay asserted for the life of the program.
5371 //
5372 // No button task and no force-pairing ceremony: a stock kit has
5373 // no user button at all (the carrier's K1 footprint ships bare),
5374 // so this board is headless by construction. The shutdown task
5375 // still runs, but only the low-battery cutoff can reach it, and
5376 // it arms no wake source.
5377 #[cfg(feature = "board-xiao-nrf52")]
5378 {
5379 let saadc = Saadc::new(
5380 p.SAADC,
5381 Irqs,
5382 SaadcConfig::default(),
5383 [ChannelConfig::single_ended(p.P0_31)],
5384 );
5385 let divider_low = Output::new(p.P0_14, Level::Low, OutputDrive::Standard);
5386 let charge_status_n = Input::new(p.P0_17, Pull::None);
5387 // 100 mA. Sensible for anything above ~500 mAh, but it is a
5388 // 1C-plus rate for a small cell — the kit ships without one,
5389 // so the pack is whatever the user attached.
5390 let charge_current_hi = Output::new(p.P0_13, Level::Low, OutputDrive::Standard);
5391 spawner.spawn(
5392 xiao_power_task(saadc, divider_low, charge_status_n, charge_current_hi).unwrap(),
5393 );
5394 spawner.spawn(xiao_shutdown_task().unwrap());
5395 }
5396
5397 // T-Echo battery monitor: SAADC on AIN2/P0.04. Same SAADC
5398 // configuration as the other two boards (12-bit, GAIN1_6, 0.6 V
5399 // ref), so only the BSP divider constant differs — this board's
5400 // 150k/150k bridge is hard-wired, with no gate pin to own.
5401 #[cfg(feature = "board-techo")]
5402 {
5403 let saadc = Saadc::new(
5404 p.SAADC,
5405 Irqs,
5406 SaadcConfig::default(),
5407 [ChannelConfig::single_ended(p.P0_04)],
5408 );
5409 spawner.spawn(techo_power_task(saadc).unwrap());
5410 }
5411
5412 // Wio Tracker L1 peripherals: SH1106 OLED on TWIM0, nav button,
5413 // piezo, and the SAADC battery monitor on AIN7/P0.31 behind the
5414 // active-high divider gate on P0.04.
5415 #[cfg(feature = "board-wio-tracker-l1")]
5416 {
5417 // TWIM EasyDMA reads from SRAM, so the driver needs a static
5418 // scratch buffer; one frame page plus the control byte is the
5419 // largest transfer it makes.
5420 static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new();
5421 let i2c = embassy_nrf::twim::Twim::new(
5422 p.TWISPI0,
5423 Irqs,
5424 p.P0_06, // SDA
5425 p.P0_05, // SCL
5426 embassy_nrf::twim::Config::default(),
5427 TWIM_BUF.init([0; 256]),
5428 );
5429 spawner.spawn(oled_display_task(display::Sh1106::new(i2c)).unwrap());
5430
5431 let mut buzzer_config = SimpleConfig::default();
5432 buzzer_config.prescaler = Prescaler::Div16;
5433 let buzzer_pwm = SimplePwm::new_1ch(p.PWM0, p.P1_00, &buzzer_config);
5434 spawner.spawn(wio_buzzer_task(buzzer_pwm).unwrap());
5435
5436 let saadc = Saadc::new(
5437 p.SAADC,
5438 Irqs,
5439 SaadcConfig::default(),
5440 [ChannelConfig::single_ended(p.P0_31)],
5441 );
5442 let divider_gate = Output::new(p.P0_04, Level::Low, OutputDrive::Standard);
5443 spawner.spawn(wio_power_task(saadc, divider_gate).unwrap());
5444
5445 // D13 / P0.08, active-low with a pull-up (MeshCore configures
5446 // every button on this board as INPUT_PULLUP).
5447 let button = Input::new(p.P0_08, Pull::Up);
5448 spawner.spawn(button_task(button).unwrap());
5449 spawner.spawn(wio_shutdown_task().unwrap());
5450
5451 // Quectel L76K on UARTE0. `BufferedUarte` rather than a plain
5452 // one because NMEA arrives as lines of unpredictable length:
5453 // a plain read would block until its buffer filled, holding a
5454 // complete sentence hostage to the start of the next one.
5455 //
5456 // TIMER1 and PPI 0/1 with group 0 are free — MPSL holds
5457 // TIMER0 and PPI 19/30/31, the softdevice controller holds
5458 // 17/18 and 20–29, and the freeze diagnostics hold TIMER2.
5459 #[cfg(feature = "cap-gnss")]
5460 {
5461 let mut gnss_config = UarteConfig::default();
5462 gnss_config.baudrate = UarteBaudrate::Baud9600;
5463 static GNSS_RX: StaticCell<[u8; 256]> = StaticCell::new();
5464 static GNSS_TX: StaticCell<[u8; 16]> = StaticCell::new();
5465 let gnss_uart = BufferedUarte::new(
5466 p.UARTE0,
5467 p.TIMER1,
5468 p.PPI_CH0,
5469 p.PPI_CH1,
5470 p.PPI_GROUP0,
5471 // rxd, then txd. The board notes contradict themselves
5472 // about which of D6/D7 carries NMEA; measured, it is
5473 // P0.26 — the family rule that `GPS_RX_PIN` is the
5474 // MCU's RX, which now holds on all four boards. See
5475 // docs/hardware/seeed-wio-tracker-l1-pro-hardware.md.
5476 p.P0_26,
5477 p.P0_27,
5478 Irqs,
5479 gnss_config,
5480 GNSS_RX.init([0; 256]),
5481 // Nothing is sent to this receiver: the L76K needs no
5482 // configuration to emit what UMSH reads. The buffer is
5483 // the smallest the driver will take.
5484 GNSS_TX.init([0; 16]),
5485 );
5486 spawner.spawn(gnss_task(gnss_uart, BoardGnss::new(p.P1_09)).unwrap());
5487 }
5488 }
5489
5490 // Dedicated power button (enclosure "PWR", P1.01) + System OFF
5491 // teardown. LED_A (P0.15, white) is the power-off acknowledgement
5492 // blinker, claimed early because the force-pairing ceremony also
5493 // blinks it; the heartbeat keeps LED_B (P0.19). P1.01 is the physical
5494 // power button (MeshCore's PIN_USER_BTN); P1.07 (enclosure "USR") is
5495 // the secondary user button and carries the force-pairing gesture.
5496 #[cfg(feature = "power-button")]
5497 {
5498 let pwr_button = Input::new(p.P1_01, Pull::Up);
5499 spawner.spawn(sensecap_pwr_button_task(pwr_button).unwrap());
5500 // LED_A passes from the boot ceremony to the task that owns it
5501 // for the rest of the run.
5502 spawner.spawn(sensecap_attention_led_task(pwr_led).unwrap());
5503 spawner.spawn(sensecap_usr_button_task(usr_button).unwrap());
5504 spawner.spawn(sensecap_shutdown_task().unwrap());
5505 }
5506
5507 #[cfg(not(feature = "t1000e"))]
5508 drop(ux_store);
5509
5510 super::panic::breadcrumb_mark(9);
5511 #[cfg(not(feature = "no-ble"))]
5512 join(ble_app(controller, ble_store), async {
5513 super::panic::breadcrumb_mark(13);
5514 usb.run().await
5515 })
5516 .await;
5517 #[cfg(feature = "no-ble")]
5518 {
5519 super::panic::breadcrumb_mark(13);
5520 usb.run().await;
5521 }
5522 }
5523
5524 // ─── Heartbeat + WDT pet ─────────────────────────────────────────────────
5525
5526 #[cfg(not(feature = "t1000e"))]
5527 #[embassy_executor::task]
5528 async fn heartbeat(mut led: Output<'static>, mut wdt: WatchdogHandle) -> ! {
5529 let mut engine = LedEngine::new(LedTimings::default(), Instant::now().as_millis());
5530 loop {
5531 wdt.pet();
5532 super::panic::breadcrumb_beat();
5533 // The locate alert outranks every other use of the LED,
5534 // including the pairing blink: someone is looking for this
5535 // board right now, and on a board with no buzzer the blink
5536 // is the entire alert.
5537 //
5538 // Not on the Solar P1, which has a second LED. There the alert
5539 // belongs on LED_A (white) alongside the other things meant to
5540 // be seen from a distance, and this one stays the status light
5541 // — see `sensecap_attention_led_task`.
5542 #[cfg(not(feature = "power-button"))]
5543 if alert_active() {
5544 engine.start_alert(Instant::now().as_millis());
5545 } else {
5546 engine.stop_alert();
5547 }
5548 // The pairing blink yields to the alert on a one-LED board,
5549 // where they would otherwise be fighting over the same pin. On
5550 // the Solar P1 they are on different LEDs and can both run.
5551 #[cfg(not(feature = "power-button"))]
5552 let alert_holds_the_led = alert_active();
5553 #[cfg(feature = "power-button")]
5554 let alert_holds_the_led = false;
5555 let ble_mode = BLE_LED_MODE.load(Ordering::Acquire);
5556 if ble_mode != 0 && !alert_holds_the_led {
5557 let phase = Instant::now().as_millis() % 2_000;
5558 let on = if ble_mode == 1 {
5559 phase < 100 || (500..600).contains(&phase)
5560 } else {
5561 phase < 100 || (200..300).contains(&phase) || (400..500).contains(&phase)
5562 };
5563 #[cfg(feature = "led-active-low")]
5564 if on {
5565 led.set_low();
5566 } else {
5567 led.set_high();
5568 }
5569 #[cfg(not(feature = "led-active-low"))]
5570 if on {
5571 led.set_high();
5572 } else {
5573 led.set_low();
5574 }
5575 Timer::after_millis(50).await;
5576 continue;
5577 }
5578 let decision = engine.tick(Instant::now().as_millis());
5579 // Active-low (T-Echo P0.14) inverts; active-high (Solar P1 LED_B
5580 // P0.19) drives directly.
5581 #[cfg(feature = "led-active-low")]
5582 if decision.on {
5583 led.set_low()
5584 } else {
5585 led.set_high()
5586 }
5587 #[cfg(not(feature = "led-active-low"))]
5588 if decision.on {
5589 led.set_high()
5590 } else {
5591 led.set_low()
5592 }
5593 // An alert edge must reach the LED without waiting out the
5594 // heartbeat's multi-second deadline. A `Signal` has one useful
5595 // consumer, so on the Solar P1 this arm is gone entirely and
5596 // `ALERT_CHANGED` belongs to the attention LED that shows the
5597 // alert — two waiters would leave whichever registered first
5598 // asleep through the edge.
5599 #[cfg(not(feature = "power-button"))]
5600 let _ = select(
5601 Timer::at(Instant::from_millis(decision.next_deadline_ms)),
5602 ALERT_CHANGED.wait(),
5603 )
5604 .await;
5605 #[cfg(feature = "power-button")]
5606 Timer::at(Instant::from_millis(decision.next_deadline_ms)).await;
5607 }
5608 }
5609
5610 /// Ambient-sampling cadence for indicator dimming while on battery.
5611 /// Room light changes on the scale of minutes, and every sample
5612 /// cycles the sensor rail through 800 conversions, so once a minute
5613 /// is as often as the battery should pay for it.
5614 #[cfg(feature = "t1000e")]
5615 const AMBIENT_INTERVAL_BATTERY: Duration = Duration::from_secs(60);
5616
5617 /// Cadence on external power, where the energy is free and the
5618 /// indicator can follow changing light closely.
5619 #[cfg(feature = "t1000e")]
5620 const AMBIENT_INTERVAL_EXTERNAL: Duration = Duration::from_secs(10);
5621
5622 /// Written brightness (permille) at or below which the indicator is
5623 /// dark enough that the sampler's ~80 ms LED blackout is invisible.
5624 /// Wide enough that the charging breathe spends a comfortable window
5625 /// under it around each trough.
5626 #[cfg(feature = "t1000e")]
5627 const AMBIENT_NEAR_DARK_PERMILLE: u16 = 20;
5628
5629 /// Request an ambient light sample when one is due and the duty just
5630 /// written is near-dark.
5631 ///
5632 /// The LED task drives the cadence because only it knows the
5633 /// animation phase: a request made at a dark phase lets the
5634 /// sampler's blanking handshake confirm against an LED that is
5635 /// already off, so the measurement never visibly interrupts what the
5636 /// indicator is showing — the charging breathe in particular. Every
5637 /// state has dark phases (heartbeat gap, breathing trough, blink
5638 /// gaps), so sampling is never starved for a window.
5639 ///
5640 /// Fire-and-forget: the result is read back from
5641 /// [`ambient_millilux`](umsh_bsp_t1000e::light::ambient_millilux)
5642 /// on a later iteration, so this task keeps servicing the blanking
5643 /// handshake while the measurement runs.
5644 #[cfg(feature = "t1000e")]
5645 fn maybe_request_ambient_sample(last_sample: &mut Option<Instant>, brightness_permille: u16) {
5646 if brightness_permille > AMBIENT_NEAR_DARK_PERMILLE {
5647 return;
5648 }
5649 // Evaluated against the current power source on every
5650 // opportunity, so an unplug can never carry the fast external
5651 // cadence onto the battery.
5652 let interval = if umsh_bsp_t1000e::power::usb_power_present() {
5653 AMBIENT_INTERVAL_EXTERNAL
5654 } else {
5655 AMBIENT_INTERVAL_BATTERY
5656 };
5657 if last_sample.is_none_or(|taken| taken.elapsed() >= interval) {
5658 *last_sample = Some(Instant::now());
5659 umsh_bsp_t1000e::light::request_sample();
5660 }
5661 }
5662
5663 /// Write one LED duty, honouring the ambient-light blanking gate.
5664 ///
5665 /// Every duty write on this board goes through here so no future
5666 /// indicator state can accidentally light the LED during a
5667 /// measurement. The confirmation is raised **after** the write, so
5668 /// the sampler is told the LED is dark only once it actually is.
5669 #[cfg(feature = "t1000e")]
5670 fn write_led_duty(led: &mut SimplePwm<'static>, duty: u16) {
5671 let blanking = umsh_bsp_t1000e::indicator::blank_requested();
5672 let duty = if blanking { 0 } else { duty };
5673 led.set_duty(0, DutyCycle::inverted(duty));
5674 if blanking {
5675 umsh_bsp_t1000e::indicator::confirm_blanked();
5676 }
5677 }
5678
5679 #[cfg(feature = "t1000e")]
5680 #[embassy_executor::task]
5681 async fn heartbeat(mut led: SimplePwm<'static>, mut wdt: WatchdogHandle) -> ! {
5682 led.set_period(1_000);
5683 led.enable();
5684 let mut engine = T1000eLedEngine::new(Instant::now().as_millis());
5685 // `None` at boot, so the first dark phase — within the first
5686 // heartbeat interval — takes the first reading.
5687 let mut last_ambient_sample: Option<Instant> = None;
5688 loop {
5689 wdt.pet();
5690 super::panic::breadcrumb_beat();
5691 let battery = umsh_bsp_t1000e::battery_state();
5692 engine.set_battery(battery);
5693 engine.set_attention(umsh_bsp_t1000e::indicator::attention_requested());
5694 engine.set_ambient_millilux(umsh_bsp_t1000e::light::ambient_millilux());
5695 // Outranks the pairing blink, the battery states, and the
5696 // one-shot sequences alike (see `T1000eLedEngine::tick`).
5697 if alert_active() {
5698 engine.start_alert(Instant::now().as_millis());
5699 } else {
5700 engine.stop_alert();
5701 }
5702
5703 let ble_mode = BLE_LED_MODE.load(Ordering::Acquire);
5704 if ble_mode != 0
5705 && !alert_active()
5706 && matches!(
5707 battery,
5708 umsh_ux_tracker::battery::BatteryState::BatteryOnly
5709 | umsh_ux_tracker::battery::BatteryState::BatteryCharged
5710 )
5711 {
5712 let phase = Instant::now().as_millis() % 2_000;
5713 let on = if ble_mode == 1 {
5714 phase < 100 || (500..600).contains(&phase)
5715 } else {
5716 phase < 100 || (200..300).contains(&phase) || (400..500).contains(&phase)
5717 };
5718 // This branch bypasses the engine, so it applies the
5719 // ambient dim itself — the connection blink dims with
5720 // the room like everything else.
5721 let brightness = if on {
5722 umsh_ux_tracker::led::ambient_dim_permille(
5723 umsh_bsp_t1000e::light::ambient_millilux(),
5724 )
5725 } else {
5726 0
5727 };
5728 let duty = ((u32::from(led.max_duty()) * u32::from(brightness)) / 1_000) as u16;
5729 write_led_duty(&mut led, duty);
5730 maybe_request_ambient_sample(&mut last_ambient_sample, brightness);
5731 match select(
5732 Timer::after_millis(50),
5733 umsh_bsp_t1000e::indicator::LED_BLANK_CHANGED.wait(),
5734 )
5735 .await
5736 {
5737 Either::First(()) | Either::Second(()) => {}
5738 }
5739 continue;
5740 }
5741
5742 let decision = engine.tick(Instant::now().as_millis());
5743 let duty =
5744 ((u32::from(led.max_duty()) * u32::from(decision.brightness)) / 1_000) as u16;
5745 write_led_duty(&mut led, duty);
5746 maybe_request_ambient_sample(&mut last_ambient_sample, decision.brightness);
5747 match select(
5748 select4(
5749 select(
5750 Timer::at(Instant::from_millis(decision.next_deadline_ms)),
5751 ALERT_CHANGED.wait(),
5752 ),
5753 umsh_bsp_t1000e::BATTERY_STATE_CHANGED.wait(),
5754 umsh_bsp_t1000e::indicator::INDICATOR_CHANGED.wait(),
5755 umsh_bsp_t1000e::indicator::LED_SEQUENCE_SIGNAL.wait(),
5756 ),
5757 // An ambient light measurement wants the LED dark, and
5758 // wants it now — it waits on the confirmation below.
5759 umsh_bsp_t1000e::indicator::LED_BLANK_CHANGED.wait(),
5760 )
5761 .await
5762 {
5763 Either::First(Either4::First(_))
5764 | Either::First(Either4::Second(_))
5765 | Either::First(Either4::Third(()))
5766 | Either::Second(()) => {}
5767 Either::First(Either4::Fourth(sequence)) => {
5768 engine.play(sequence, Instant::now().as_millis());
5769 }
5770 }
5771 }
5772 }
5773}