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, RegionArg, 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    Regions {
29        #[command(subcommand)]
30        op: Option<RegionOp>,
31    },
32    /// Tag untagged floods with this region before forwarding; `none`
33    /// forwards them untagged.
34    DefaultRegion {
35        #[arg(value_name = "CODE|none")]
36        code: OptRegionArg,
37    },
38    /// Only forward frames heard at or above this RSSI.
39    MinRssi {
40        #[arg(value_name = "DBM|none", allow_hyphen_values = true)]
41        dbm: MinRssiArg,
42    },
43    /// Only forward frames heard at or above this SNR.
44    MinSnr {
45        #[arg(value_name = "DB|none", allow_hyphen_values = true)]
46        db: MinSnrArg,
47    },
48}
49
50/// A region is written as a short code, a name, or a literal
51/// `0x1234`; the device stores the string and derives the 2-octet code
52/// its forwarding filter compares.
53#[derive(Debug, clap::Subcommand)]
54pub enum RegionOp {
55    /// List the regions the device forwards for.
56    List,
57    /// Add one region to the filter.
58    Add {
59        #[arg(value_name = "REGION")]
60        region: RegionArg,
61    },
62    /// Remove one region from the filter.
63    Remove {
64        #[arg(value_name = "REGION")]
65        region: RegionArg,
66    },
67    /// Replace the whole filter; `none` clears it, which forwards
68    /// regardless of region.
69    Set {
70        #[arg(value_name = "REGION[,...]|none")]
71        list: RegionListArg,
72    },
73}
74
75pub async fn run(app: &mut App, op: Option<RepeaterOp>) -> Result<()> {
76    let no_save = app.no_save;
77    let device = app.device()?;
78    match op.unwrap_or(RepeaterOp::Show) {
79        RepeaterOp::Show => {
80            let Some(policy) = device.repeater_policy().await? else {
81                bail!("device does not advertise CAP_REPEATER");
82            };
83            print_enabled(Some(policy.enabled as u8));
84            subfield("regions", format_regions(&policy.regions));
85            match policy.default_region {
86                Some(code) => subfield("default", code),
87                None => subfield("default", "none (forwards untagged floods untagged)"),
88            }
89            match policy.min_rssi {
90                Some(dbm) => subfield("min rssi", format!("{dbm} dBm")),
91                None => subfield("min rssi", "any"),
92            }
93            match policy.min_snr {
94                Some(db) => subfield("min snr", format!("{db} dB")),
95                None => subfield("min snr", "any"),
96            }
97            if policy.enabled
98                && policy.default_region.is_some_and(|code| {
99                    !policy.regions.is_empty() && !region_codes(&policy.regions).contains(&code)
100                })
101            {
102                // Legal, and not enforced by the device: the two
103                // properties are written independently. Still almost
104                // always a mistake, since this repeater tags floods with
105                // a region it will not itself forward.
106                warn(
107                    "the default region is not in the forwarding list; floods this repeater \
108                     tags will not be forwarded by it",
109                );
110            }
111            return Ok(());
112        }
113        RepeaterOp::On => set_enabled(device, true).await?,
114        RepeaterOp::Off => set_enabled(device, false).await?,
115        RepeaterOp::Regions { op } => match op.unwrap_or(RegionOp::List) {
116            RegionOp::List => {
117                let regions = device.repeater_regions().await?;
118                println!("repeater regions {}", format_regions(&regions));
119                return Ok(());
120            }
121            RegionOp::Add { region } => {
122                device.add_repeater_region(&region.0).await?;
123                println!("repeater region added: {}", format_region(&region.0));
124            }
125            RegionOp::Remove { region } => {
126                device.remove_repeater_region(&region.0).await?;
127                println!("repeater region removed: {}", format_region(&region.0));
128            }
129            RegionOp::Set { list } => {
130                let stored = device.set_repeater_regions(&list.0).await?;
131                println!("repeater regions {}", format_regions(&stored));
132                if stored.len() < list.0.len() {
133                    warn(format!(
134                        "the device kept {} of {} regions (repeats collapse)",
135                        stored.len(),
136                        list.0.len()
137                    ));
138                }
139            }
140        },
141        RepeaterOp::DefaultRegion { code } => {
142            match device.set_repeater_default_region(code.0).await? {
143                Some(code) => println!("repeater default region {code}"),
144                None => println!("repeater default region none (forwards untagged)"),
145            }
146        }
147        RepeaterOp::MinRssi { dbm } => match device.set_repeater_min_rssi(dbm.0).await? {
148            Some(dbm) => println!("repeater min rssi {dbm} dBm"),
149            None => println!("repeater min rssi any"),
150        },
151        RepeaterOp::MinSnr { db } => match device.set_repeater_min_snr(db.0).await? {
152            Some(db) => println!("repeater min snr {db} dB"),
153            None => println!("repeater min snr any"),
154        },
155    }
156    persist(device, no_save).await
157}
158
159async fn set_enabled(
160    device: &mut umsh::ulcp::UlcpDevice<crate::connection::SessionLink>,
161    enabled: bool,
162) -> Result<()> {
163    let echoed = device
164        .set_prop(prop::MAC_REPEATER_ENABLED, &[enabled as u8])
165        .await?;
166    print_enabled(echoed.first().copied());
167    Ok(())
168}
169
170fn print_enabled(byte: Option<u8>) {
171    match byte {
172        Some(0) => println!("repeater off"),
173        Some(_) => println!("repeater on (on-board node forwards overheard frames)"),
174        None => println!("repeater state unknown (empty value)"),
175    }
176}
177
178/// The codes a stored region list derives to, for the cross-checks that
179/// compare against a default region.
180fn region_codes(regions: &[String]) -> Vec<RegionCode> {
181    regions
182        .iter()
183        .filter_map(|region| region.parse::<RegionCode>().ok())
184        .collect()
185}
186
187/// Render one region as the operator wrote it, with the code the
188/// forwarding filter actually compares — a hashed name is otherwise
189/// unrecognizable in a packet capture.
190///
191/// A short code is shown uppercase whatever case it was written in, which
192/// is how airport and country codes are written everywhere else. A name is
193/// the operator's to capitalize and is left alone.
194pub fn format_region(region: &str) -> String {
195    let Ok(code) = region.parse::<RegionCode>() else {
196        return region.to_string();
197    };
198    let hex = format!("0x{:04X}", code.as_u16());
199    if region.eq_ignore_ascii_case(&hex) {
200        return hex;
201    }
202    match RegionCode::from_short_code(region).is_ok() {
203        true => format!("{} ({hex})", region.to_uppercase()),
204        false => format!("{region} ({hex})"),
205    }
206}
207
208/// Render a region list for display, naming the empty list as what it
209/// means rather than printing nothing.
210pub fn format_regions(regions: &[String]) -> String {
211    if regions.is_empty() {
212        return "any (no regional restriction)".to_string();
213    }
214    regions
215        .iter()
216        .map(|region| format_region(region))
217        .collect::<Vec<_>>()
218        .join(", ")
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn the_empty_region_list_says_what_it_means() {
227        assert_eq!(format_regions(&[]), "any (no regional restriction)");
228    }
229
230    #[test]
231    fn a_region_prints_the_code_its_string_derives_to() {
232        assert_eq!(format_region("SJC"), "SJC (0x7853)");
233        assert_eq!(format_region("Rogue Valley"), "Rogue Valley (0xC0F9)");
234        // A literal already is its code; quoting it twice says nothing.
235        assert_eq!(format_region("0x1234"), "0x1234");
236    }
237
238    #[test]
239    fn a_short_code_prints_uppercase_however_it_was_written() {
240        assert_eq!(format_region("sjc"), "SJC (0x7853)");
241        assert_eq!(format_region("Sjc"), "SJC (0x7853)");
242        assert_eq!(format_region("wa"), "WA (0x8FE8)");
243        // A digit-bearing short code has no reading, but it is still a
244        // code and is written like one.
245        assert_eq!(format_region("w7"), format!("W7 (0x{:04X})", short("w7")));
246        // A name keeps the operator's capitalization.
247        assert_eq!(format_region("rogue valley"), "rogue valley (0xC0F9)");
248    }
249
250    fn short(code: &str) -> u16 {
251        RegionCode::from_short_code(code).unwrap().as_u16()
252    }
253}