1use std::path::Path;
18use std::time::Duration;
19
20use anyhow::{Context, Result, anyhow, bail};
21use rand::{Rng as _, rng};
22use tokio::time::Instant;
23
24use umsh::core::PublicKey;
25use umsh::crypto::{
26 CryptoEngine, NodeIdentity,
27 software::{SoftwareAes, SoftwareIdentity, SoftwareSha256},
28};
29use umsh::hal::Radio;
30use umsh::mac::{Mac, MacHandle, OperatingPolicy, RepeaterConfig};
31use umsh::node::{Host, LocalNode};
32use umsh::node_mgmt::admin::{Failure, Outcome};
33use umsh::node_mgmt::{NodeManager, Progress};
34use umsh::tokio_support::{StdClock, TokioFileCounterStore, TokioFileKeyValueStore, TokioPlatform};
35use umsh::ulcp::{UlcpDevice, UlcpDeviceConfig, UlcpError};
36use umsh::ulcp_mesh::{
37 DeliveredOutcome, MeshEndpoint, MeshFault, MeshFrameLink, MeshRequest, mesh_link,
38};
39use umsh::ulcp_wire::ids::prop;
40use umsh_sync::AsyncRefCell;
41
42use crate::App;
43use crate::connection::{self, Session, SessionLink};
44use crate::output::{field, note};
45
46const IDENTITIES: usize = 1;
59const PEERS: usize = 32;
60const CHANNELS: usize = 1;
61const ACKS: usize = 8;
62const TX: usize = 8;
63const FRAME: usize = 256;
64const DUP: usize = 32;
65
66pub type CtlPlatform<R> = TokioPlatform<R, TokioFileCounterStore, TokioFileKeyValueStore>;
67pub type CtlMac<R> = Mac<CtlPlatform<R>, IDENTITIES, PEERS, CHANNELS, ACKS, TX, FRAME, DUP>;
68pub type CtlHandle<'a, R> =
69 MacHandle<'a, CtlPlatform<R>, IDENTITIES, PEERS, CHANNELS, ACKS, TX, FRAME, DUP>;
70pub type CtlHost<'a, R> = Host<CtlHandle<'a, R>>;
71
72pub const OPERATION_TIMEOUT: Duration = Duration::from_secs(180);
78
79pub fn admin_identity() -> Result<SoftwareIdentity> {
86 let path = connection::admin_identity_path()
87 .ok_or_else(|| anyhow!("no HOME directory to keep an administrator identity in"))?;
88 load_or_create_identity(&path)
89}
90
91fn load_or_create_identity(path: &Path) -> Result<SoftwareIdentity> {
92 match std::fs::read(path) {
93 Ok(bytes) => {
94 let secret: [u8; 32] = bytes.try_into().map_err(|_| {
95 anyhow!(
96 "{} is not a 32-byte identity seed; move it aside to start over",
97 path.display()
98 )
99 })?;
100 Ok(SoftwareIdentity::from_secret_bytes(&secret))
101 }
102 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
103 if let Some(parent) = path.parent() {
104 std::fs::create_dir_all(parent)
105 .with_context(|| format!("creating {}", parent.display()))?;
106 }
107 let mut secret = [0u8; 32];
108 rng().fill_bytes(&mut secret);
109 std::fs::write(path, secret).with_context(|| format!("writing {}", path.display()))?;
110 note("generated a new administrator identity");
111 Ok(SoftwareIdentity::from_secret_bytes(&secret))
112 }
113 Err(error) => Err(error).with_context(|| format!("reading {}", path.display())),
114 }
115}
116
117pub fn show_admin_key() -> Result<()> {
120 let identity = admin_identity()?;
121 field("administrator", identity.public_key().to_string());
122 if let Some(path) = connection::admin_identity_path() {
123 field("identity", path.display());
124 }
125 note("a device lists this key under `dev-admin add` before it will answer");
126 Ok(())
127}
128
129pub async fn adopt_phy(device: &mut UlcpDevice<SessionLink>) -> Result<UlcpDeviceConfig> {
144 let keys = [
145 prop::PHY_FREQ,
146 prop::PHY_LORA_BW,
147 prop::PHY_LORA_SF,
148 prop::PHY_LORA_CR,
149 prop::PHY_TX_POWER,
150 prop::PHY_ENABLED,
151 ];
152 let answers = device.get_props(&keys).await?;
153 let mut config = connection::attach_config();
154 for (requested, answer) in keys.iter().zip(&answers) {
155 let Ok((_, value)) = answer else { continue };
156 match *requested {
157 prop::PHY_ENABLED => {
158 if value.first() == Some(&0) {
159 bail!("the radio's PHY is switched off; turn it on with `set phy-enabled on`");
160 }
161 }
162 prop::PHY_FREQ => {
163 if let Ok(bytes) = <[u8; 4]>::try_from(&value[..]) {
164 config.freq_khz = u32::from_le_bytes(bytes);
165 }
166 }
167 prop::PHY_LORA_BW => {
168 if let Ok(bytes) = <[u8; 4]>::try_from(&value[..]) {
169 config.bandwidth_hz = u32::from_le_bytes(bytes);
170 }
171 }
172 prop::PHY_LORA_SF => {
173 if let Some(&sf) = value.first() {
174 config.spreading_factor = sf;
175 }
176 }
177 prop::PHY_LORA_CR => {
178 if let Some(&cr) = value.first() {
179 config.coding_rate_denom = cr;
180 }
181 }
182 prop::PHY_TX_POWER => {
183 if let Some(&power) = value.first() {
184 config.tx_power_dbm = power as i8;
185 }
186 }
187 _ => {}
188 }
189 }
190 Ok(config)
191}
192
193pub async fn prepare_radio(device: &mut UlcpDevice<SessionLink>) -> Result<()> {
197 match device.set_prop(prop::MAC_PROMISCUOUS, &[1]).await {
198 Ok(_) => Ok(()),
199 Err(UlcpError::Status(status)) => {
200 note(format!(
201 "radio refused promiscuous mode ({status:?}); reception follows its own filtering"
202 ));
203 Ok(())
204 }
205 Err(error) => Err(error.into()),
206 }
207}
208
209pub fn counter_store() -> Result<TokioFileCounterStore> {
214 let path = connection::admin_counter_path()
215 .ok_or_else(|| anyhow!("no HOME directory to keep frame counters in"))?;
216 TokioFileCounterStore::new(path)
217 .map_err(|error| anyhow!("opening the counter store: {error:?}"))
218}
219
220pub trait RadioErrand {
230 async fn run<R: Radio>(
231 self,
232 mac: &AsyncRefCell<CtlMac<R>>,
233 identity: SoftwareIdentity,
234 ) -> Result<()>
235 where
236 R::Error: core::fmt::Debug;
237}
238
239pub async fn borrowing_the_radio<E: RadioErrand>(app: &mut App, errand: E) -> Result<()> {
246 let identity = admin_identity()?;
247 let config = adopt_phy(app.device()?).await?;
250
251 let Some(session) = app.session.take() else {
252 bail!("not attached — try `ble-scan` or `connect`");
253 };
254 let Session {
255 device,
256 target,
257 label,
258 tap,
259 } = session;
260 let mut device = UlcpDevice::attach_administrative(device.into_link(), config)
264 .await
265 .context("re-attaching the radio with its own PHY")?;
266 if app.trace {
267 connection::install_trace(&mut device);
268 }
269 prepare_radio(&mut device).await?;
270
271 let (device, result) = match counter_store() {
272 Ok(store) => {
273 let mac = build_mac(device, store);
274 let result = errand.run(&mac, identity).await;
275 (mac.into_inner().into_radio(), result)
276 }
277 Err(error) => (device, Err(error)),
278 };
279 app.session = Some(Session {
280 device,
281 target,
282 label,
283 tap,
284 });
285 result
286}
287
288pub fn build_mac<R: Radio>(radio: R, store: TokioFileCounterStore) -> AsyncRefCell<CtlMac<R>> {
290 AsyncRefCell::new(Mac::new(
291 radio,
292 CryptoEngine::new(SoftwareAes, SoftwareSha256),
293 StdClock::new(),
294 rng(),
295 store,
296 RepeaterConfig::default(),
297 OperatingPolicy::default(),
298 ))
299}
300
301pub struct NodeStack<'a, R: Radio> {
305 pub host: CtlHost<'a, R>,
306 pub node: LocalNode<CtlHandle<'a, R>>,
307 pub handle: CtlHandle<'a, R>,
308 started: Instant,
309}
310
311impl<'a, R: Radio> NodeStack<'a, R>
312where
313 R::Error: core::fmt::Debug,
314{
315 pub async fn build(
318 mac: &'a AsyncRefCell<CtlMac<R>>,
319 identity: SoftwareIdentity,
320 ) -> Result<(Self, PublicKey)> {
321 let handle = MacHandle::new(mac);
322 let local_key = *identity.public_key();
323 let identity_id = handle
324 .add_identity(identity)
325 .await
326 .map_err(|error| anyhow!("registering the administrator identity: {error:?}"))?;
327 handle
330 .load_persisted_counter(identity_id)
331 .await
332 .map_err(|error| anyhow!("loading persisted frame counters: {error:?}"))?;
333
334 let mut host: CtlHost<'a, R> = Host::new(handle);
335 let node = host.add_node(identity_id);
336 Ok((
337 Self {
338 host,
339 node,
340 handle,
341 started: Instant::now(),
342 },
343 local_key,
344 ))
345 }
346
347 pub fn started(&self) -> Instant {
350 self.started
351 }
352
353 pub fn now_ms(&self) -> u64 {
354 self.started.elapsed().as_millis() as u64
355 }
356
357 pub async fn pump_until(&mut self, deadline: Instant) -> Result<()> {
362 tokio::select! {
363 result = self.host.pump_once() => {
364 result.map_err(|error| anyhow!("the radio stopped answering: {error:?}"))?;
365 }
366 _ = tokio::time::sleep_until(deadline) => {}
367 }
368 self.host.service_protocol_timeouts().await;
369 let _ = self.handle.service_counter_persistence().await;
370 Ok(())
371 }
372
373 pub async fn exchange(
376 &mut self,
377 manager: &mut NodeManager<CtlHandle<'a, R>>,
378 request: &[u8],
379 give_up: Instant,
380 ) -> Result<Outcome> {
381 manager
382 .begin(request, self.now_ms())
383 .map_err(|error| anyhow!("{error:?}"))?;
384 loop {
385 if Instant::now() > give_up {
386 bail!("gave up after {} s", OPERATION_TIMEOUT.as_secs());
387 }
388 let progress = manager
389 .service(self.now_ms())
390 .await
391 .map_err(|error| anyhow!("sending to the device: {error:?}"))?;
392 let deadline_ms = match progress {
393 Progress::Done(outcome) => return Ok(outcome),
394 Progress::Waiting { deadline_ms } => deadline_ms,
395 };
396 let wait = Duration::from_millis(deadline_ms.saturating_sub(self.now_ms()));
397 self.pump_until(Instant::now() + wait).await?;
398 }
399 }
400}
401
402pub fn describe(failure: Failure) -> anyhow::Error {
404 match failure {
405 Failure::TimedOut => anyhow!(
406 "no answer — the device may be out of range, or this tool may not be one of its \
407 administrators (`admin-key` prints the key it would have to list)"
408 ),
409 Failure::CursorInvalid => {
410 anyhow!("the device's state changed mid-read; run the command again")
411 }
412 Failure::TooLarge => anyhow!("the answer is larger than this tool reassembles"),
413 Failure::Malformed => anyhow!("the device's answer could not be read"),
414 Failure::UnknownCriticalOption(number) => {
415 anyhow!("the device's answer carries option {number}, which this tool does not know")
416 }
417 }
418}
419
420pub struct MeshHome {
428 pub driver: tokio::task::JoinHandle<UlcpDevice<SessionLink>>,
429 pub local_target: connection::Target,
430 pub local_label: String,
431 pub tap: connection::FrameTap,
432}
433
434#[derive(Clone, Copy, PartialEq, Eq)]
436pub enum Greeting {
437 Named,
441 Silent,
444}
445
446pub async fn open_remote(app: &mut App, target: PublicKey, greeting: Greeting) -> Result<()> {
454 if app.mesh.is_some() {
455 bail!("already on a mesh session — `disconnect` returns to the radio");
456 }
457 let identity = admin_identity()?;
458
459 let phy = adopt_phy(app.device()?).await?;
462 let store = counter_store()?;
463
464 let Some(session) = app.session.take() else {
465 bail!("not attached — try `ble-scan` or `connect`");
466 };
467 let connection::Session {
468 device,
469 target: local_target,
470 label: local_label,
471 tap,
472 } = session;
473
474 let mut radio = UlcpDevice::attach_administrative(device.into_link(), phy.clone())
478 .await
479 .context("re-attaching the radio with its own PHY")?;
480 if app.trace {
481 connection::install_trace(&mut radio);
482 }
483 prepare_radio(&mut radio).await?;
484
485 let (link, endpoint) = mesh_link();
486 let driver = tokio::task::spawn_local(drive(radio, store, identity, target, endpoint));
487
488 let home = MeshHome {
491 driver,
492 local_target,
493 local_label,
494 tap,
495 };
496 match open_session(link, phy, target, greeting).await {
497 Ok(session) => {
498 app.session = Some(session);
499 app.mesh = Some(home);
500 Ok(())
501 }
502 Err(error) => {
503 restore_local(app, home).await;
506 Err(error)
507 }
508 }
509}
510
511async fn open_session(
517 link: MeshFrameLink,
518 phy: UlcpDeviceConfig,
519 target: PublicKey,
520 greeting: Greeting,
521) -> Result<connection::Session> {
522 let tap = connection::new_tap();
523 let session_link = SessionLink::new(connection::AnyLink::Mesh(link), tap.clone());
524 let mut device = UlcpDevice::open_remote(session_link, connection::mesh_attach_config(phy));
525 let label = match greeting {
528 Greeting::Silent => target.to_string(),
529 Greeting::Named => {
530 note("reaching the device over the mesh — an exchange can take a while");
531 match device.device_name().await {
532 Ok(name) if !name.is_empty() => name,
533 Ok(_) => target.to_string(),
534 Err(error) => {
535 return Err(
536 anyhow::Error::new(error).context("reaching the device over the mesh")
537 );
538 }
539 }
540 }
541 };
542 Ok(connection::Session {
543 device,
544 target: connection::Target::Mesh { key: target.0 },
545 label,
546 tap,
547 })
548}
549
550pub async fn restore_local(app: &mut App, home: MeshHome) {
555 let MeshHome {
556 driver,
557 local_target,
558 local_label,
559 tap,
560 } = home;
561 match driver.await {
562 Ok(radio) => {
563 app.session = Some(connection::Session {
564 device: radio,
565 target: local_target,
566 label: local_label,
567 tap,
568 });
569 }
570 Err(error) => {
571 note(format!(
575 "the mesh session ended badly ({error}); the radio was not recovered"
576 ));
577 }
578 }
579}
580
581async fn drive(
584 radio: UlcpDevice<SessionLink>,
585 store: TokioFileCounterStore,
586 identity: SoftwareIdentity,
587 target: PublicKey,
588 endpoint: MeshEndpoint,
589) -> UlcpDevice<SessionLink> {
590 let mac = build_mac(radio, store);
591 serve(&mac, identity, target, endpoint).await;
592 mac.into_inner().into_radio()
593}
594
595enum Step {
597 Carry(MeshRequest),
599 Pumped,
601 Closed,
603}
604
605async fn serve<R: Radio>(
606 mac: &AsyncRefCell<CtlMac<R>>,
607 identity: SoftwareIdentity,
608 target: PublicKey,
609 mut endpoint: MeshEndpoint,
610) where
611 R::Error: core::fmt::Debug,
612{
613 let (mut stack, _local_key) = match NodeStack::build(mac, identity).await {
614 Ok(built) => built,
615 Err(error) => return endpoint.fail(MeshFault::Radio(format!("{error:#}"))),
616 };
617 let peer = match stack.node.peer(target).await {
618 Ok(peer) => peer,
619 Err(error) => {
620 return endpoint.fail(MeshFault::Radio(format!(
621 "registering the device as a peer: {error:?}"
622 )));
623 }
624 };
625 let mut routes = crate::routes::RouteCache::load();
630 if let Some(record) = routes.get(&target) {
631 peer.restore_route(record.route.clone()).await;
632 }
633
634 let mut seed = [0u8; 2];
637 rng().fill_bytes(&mut seed);
638 let mut manager = NodeManager::new(peer, u16::from_be_bytes(seed));
639
640 let mut fatal = None;
641 loop {
642 let step = tokio::select! {
645 request = endpoint.next() => match request {
646 Some(request) => Step::Carry(request),
647 None => Step::Closed,
648 },
649 result = stack.host.pump_once() => match result {
650 Ok(()) => Step::Pumped,
651 Err(error) => {
652 fatal = Some(MeshFault::Radio(format!(
653 "the radio stopped answering: {error:?}"
654 )));
655 break;
656 }
657 },
658 };
659 match step {
660 Step::Closed => break,
661 Step::Pumped => {
662 stack.host.service_protocol_timeouts().await;
663 let _ = stack.handle.service_counter_persistence().await;
664 }
665 Step::Carry(request) => {
666 let give_up = Instant::now() + OPERATION_TIMEOUT;
667 match stack.exchange(&mut manager, request.frame(), give_up).await {
668 Ok(Outcome::Replied { .. }) => {
669 let reply = manager.reply().to_vec();
670 endpoint.deliver(&request, DeliveredOutcome::Replied(&reply));
671 }
672 Ok(Outcome::NoResponse) => {
673 endpoint.deliver(&request, DeliveredOutcome::NoResponse)
674 }
675 Ok(Outcome::Failed(failure)) => {
676 endpoint.deliver(&request, DeliveredOutcome::Failed(failure))
677 }
678 Err(error) => endpoint.refuse(format!("{error:#}")),
684 }
685 routes.harvest(&stack.handle).await;
691 if let Err(error) = routes.store() {
692 crate::output::warn(format!("could not save learned routes: {error:#}"));
693 }
694 }
695 }
696 }
697 let _ = stack.handle.service_counter_persistence().await;
698 routes.harvest(&stack.handle).await;
699 if let Err(error) = routes.store() {
700 crate::output::warn(format!("could not save learned routes: {error:#}"));
701 }
702 if let Some(fault) = fatal {
703 endpoint.fail(fault);
704 }
705}