umshctl/command/
repeater.rs

1//! `repeater`: report or set autonomous forwarding
2//! (`PROP_MAC_REPEATER_*`).
3//!
4//! Persisted device-domain state: it takes effect once a device identity
5//! is provisioned (store-and-defer) and survives reboot. Enabling it
6//! makes the on-board node forward overheard routable frames and
7//! advertise as a repeater.
8
9use anyhow::{Result, bail};
10
11use umsh::core::RegionCode;
12use umsh::ulcp_wire::ids::prop;
13
14use super::persist;
15use super::values::{MinRssiArg, MinSnrArg, OptRegionArg, RegionListArg};
16use crate::App;
17use crate::output::{subfield, warn};
18
19#[derive(Debug, clap::Subcommand)]
20pub enum RepeaterOp {
21    /// Print the whole forwarding policy.
22    Show,
23    /// Forward overheard frames and advertise as a repeater.
24    On,
25    /// Stop forwarding.
26    Off,
27    /// Only flood-forward packets tagged with one of these regions;
28    /// `none` clears the filter, which forwards regardless of region.
29    Regions {
30        #[arg(value_name = "CODE[,...]|none")]
31        list: RegionListArg,
32    },
33    /// Tag untagged floods with this region before forwarding; `none`
34    /// forwards them untagged.
35    DefaultRegion {
36        #[arg(value_name = "CODE|none")]
37        code: OptRegionArg,
38    },
39    /// Only forward frames heard at or above this RSSI.
40    MinRssi {
41        #[arg(value_name = "DBM|none", allow_hyphen_values = true)]
42        dbm: MinRssiArg,
43    },
44    /// Only forward frames heard at or above this SNR.
45    MinSnr {
46        #[arg(value_name = "DB|none", allow_hyphen_values = true)]
47        db: MinSnrArg,
48    },
49}
50
51pub async fn run(app: &mut App, op: Option<RepeaterOp>) -> Result<()> {
52    let no_save = app.no_save;
53    let device = app.device()?;
54    match op.unwrap_or(RepeaterOp::Show) {
55        RepeaterOp::Show => {
56            let Some(policy) = device.repeater_policy().await? else {
57                bail!("device does not advertise CAP_REPEATER");
58            };
59            print_enabled(Some(policy.enabled as u8));
60            subfield("regions", format_regions(&policy.regions));
61            match policy.default_region {
62                Some(code) => subfield("default", code),
63                None => subfield("default", "none (forwards untagged floods untagged)"),
64            }
65            match policy.min_rssi {
66                Some(dbm) => subfield("min rssi", format!("{dbm} dBm")),
67                None => subfield("min rssi", "any"),
68            }
69            match policy.min_snr {
70                Some(db) => subfield("min snr", format!("{db} dB")),
71                None => subfield("min snr", "any"),
72            }
73            if policy.enabled
74                && policy.default_region.is_some_and(|code| {
75                    !policy.regions.is_empty() && !policy.regions.contains(&code)
76                })
77            {
78                // Legal, and not enforced by the device: the two
79                // properties are written independently. Still almost
80                // always a mistake, since this repeater tags floods with
81                // a region it will not itself forward.
82                warn(
83                    "the default region is not in the forwarding list; floods this repeater \
84                     tags will not be forwarded by it",
85                );
86            }
87            return Ok(());
88        }
89        RepeaterOp::On => set_enabled(device, true).await?,
90        RepeaterOp::Off => set_enabled(device, false).await?,
91        RepeaterOp::Regions { list } => {
92            let stored = device.set_repeater_regions(&list.0).await?;
93            println!("repeater regions {}", format_regions(&stored));
94            if stored.len() < list.0.len() {
95                warn(format!(
96                    "the device kept {} of {} regions (capacity)",
97                    stored.len(),
98                    list.0.len()
99                ));
100            }
101        }
102        RepeaterOp::DefaultRegion { code } => {
103            match device.set_repeater_default_region(code.0).await? {
104                Some(code) => println!("repeater default region {code}"),
105                None => println!("repeater default region none (forwards untagged)"),
106            }
107        }
108        RepeaterOp::MinRssi { dbm } => match device.set_repeater_min_rssi(dbm.0).await? {
109            Some(dbm) => println!("repeater min rssi {dbm} dBm"),
110            None => println!("repeater min rssi any"),
111        },
112        RepeaterOp::MinSnr { db } => match device.set_repeater_min_snr(db.0).await? {
113            Some(db) => println!("repeater min snr {db} dB"),
114            None => println!("repeater min snr any"),
115        },
116    }
117    persist(device, no_save).await
118}
119
120async fn set_enabled(
121    device: &mut umsh::ulcp::UlcpDevice<crate::connection::SessionLink>,
122    enabled: bool,
123) -> Result<()> {
124    let echoed = device
125        .set_prop(prop::MAC_REPEATER_ENABLED, &[enabled as u8])
126        .await?;
127    print_enabled(echoed.first().copied());
128    Ok(())
129}
130
131fn print_enabled(byte: Option<u8>) {
132    match byte {
133        Some(0) => println!("repeater off"),
134        Some(_) => println!("repeater on (on-board node forwards overheard frames)"),
135        None => println!("repeater state unknown (empty value)"),
136    }
137}
138
139/// Render a region list for display, naming the empty list as what it
140/// means rather than printing nothing.
141pub fn format_regions(regions: &[RegionCode]) -> String {
142    if regions.is_empty() {
143        return "any (no regional restriction)".to_string();
144    }
145    regions
146        .iter()
147        .map(RegionCode::to_string)
148        .collect::<Vec<_>>()
149        .join(", ")
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn the_empty_region_list_says_what_it_means() {
158        assert_eq!(format_regions(&[]), "any (no regional restriction)");
159        assert_eq!(
160            format_regions(&[RegionCode::from_iata("SJC").unwrap()]),
161            "SJC"
162        );
163    }
164}