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