umshctl/command/
advert.rs

1//! `advert`: what the device announces without being asked
2//! (`PROP_ADVERT_INTERVAL`, `PROP_BEACON_INTERVAL`,
3//! `PROP_STARTUP_BEACON`).
4//!
5//! Two schedules rather than one, because the two announcements cost very
6//! different amounts of airtime and say different things. A beacon is an
7//! empty broadcast that collects a trace on its way out, so what it
8//! publishes is the path back to the device; an advertisement carries the
9//! signed node identity and goes only as far as the device's own
10//! neighbours. A mesh normally wants the first often and the second
11//! rarely.
12
13use anyhow::{Result, bail};
14
15use umsh::ulcp::AdvertPolicy;
16use umsh::ulcp_wire::ids::{MAX_AUTO_ANNOUNCE_INTERVAL_S, MIN_AUTO_ANNOUNCE_INTERVAL_S};
17
18use super::persist;
19use crate::App;
20use crate::output::{field, subfield};
21
22#[derive(Debug, clap::Subcommand)]
23pub enum AdvertOp {
24    /// Print both schedules and the startup-beacon setting.
25    Status,
26    /// Seconds between signed identity advertisements, or `off`.
27    Interval {
28        #[arg(value_name = "SECONDS|off")]
29        period: Period,
30    },
31    /// Seconds between empty beacons, or `off`.
32    BeaconInterval {
33        #[arg(value_name = "SECONDS|off")]
34        period: Period,
35    },
36    /// Whether one beacon goes out once the device comes up.
37    Startup {
38        #[command(subcommand)]
39        op: ToggleOp,
40    },
41}
42
43/// An interval, where "off" and zero are the same thing on the wire.
44///
45/// The device holds to the same bounds; checking them here too is what
46/// turns a refusal into a sentence the operator can act on.
47#[derive(Debug, Clone, Copy)]
48pub struct Period(u32);
49
50impl std::str::FromStr for Period {
51    type Err = String;
52
53    fn from_str(text: &str) -> Result<Self, Self::Err> {
54        if text.eq_ignore_ascii_case("off") || text == "0" {
55            return Ok(Self(0));
56        }
57        let seconds: u32 = text
58            .parse()
59            .map_err(|_| format!("expected a number of seconds or `off`, got `{text}`"))?;
60        if seconds < MIN_AUTO_ANNOUNCE_INTERVAL_S {
61            return Err(format!(
62                "the shortest accepted interval is {} ({MIN_AUTO_ANNOUNCE_INTERVAL_S} s); use `off` to send none",
63                format_duration(MIN_AUTO_ANNOUNCE_INTERVAL_S)
64            ));
65        }
66        if seconds > MAX_AUTO_ANNOUNCE_INTERVAL_S {
67            return Err(format!(
68                "the longest accepted interval is {} ({MAX_AUTO_ANNOUNCE_INTERVAL_S} s); use `off` to send none",
69                format_duration(MAX_AUTO_ANNOUNCE_INTERVAL_S)
70            ));
71        }
72        Ok(Self(seconds))
73    }
74}
75
76#[derive(Debug, clap::Subcommand)]
77pub enum ToggleOp {
78    On,
79    Off,
80}
81
82impl ToggleOp {
83    fn enabled(&self) -> bool {
84        matches!(self, Self::On)
85    }
86}
87
88pub async fn run(app: &mut App, op: Option<AdvertOp>) -> Result<()> {
89    let no_save = app.no_save;
90    let device = app.device()?;
91    match op.unwrap_or(AdvertOp::Status) {
92        AdvertOp::Status => {
93            let Some(policy) = device.advert_policy().await? else {
94                bail!("device does not advertise CAP_ADVERT");
95            };
96            report(&policy);
97            return Ok(());
98        }
99        AdvertOp::Interval { period } => {
100            let stored = device.set_advert_interval(period.0).await?;
101            println!("advertisement {}", format_interval(stored));
102        }
103        AdvertOp::BeaconInterval { period } => {
104            let stored = device.set_beacon_interval(period.0).await?;
105            println!("beacon {}", format_interval(stored));
106        }
107        AdvertOp::Startup { op } => {
108            let enabled = device.set_startup_beacon(op.enabled()).await?;
109            println!("startup beacon {}", if enabled { "on" } else { "off" });
110        }
111    }
112    persist(device, no_save).await
113}
114
115fn report(policy: &AdvertPolicy) {
116    field("advert", format_interval(policy.advert_interval_s));
117    subfield("beacon", format_interval(policy.beacon_interval_s));
118    subfield(
119        "startup beacon",
120        if policy.startup_beacon { "on" } else { "off" },
121    );
122    // An interval is a floor, not a period. Said once here rather than
123    // hedged into every reading above.
124    if policy.advert_interval_s > 0 || policy.beacon_interval_s > 0 {
125        subfield("scatter", "each period runs up to 25% longer");
126    }
127}
128
129/// Seconds as written, with the human-scale reading beside them — the
130/// difference between 3600 and 36000 is easy to miss in a bare number.
131fn format_interval(seconds: u32) -> String {
132    if seconds == 0 {
133        return "off".to_string();
134    }
135    format!("every {seconds} s ({})", format_duration(seconds))
136}
137
138fn format_duration(seconds: u32) -> String {
139    match seconds {
140        s if s >= 3600 && s % 3600 == 0 => format!("{}h", s / 3600),
141        s if s >= 3600 => format!("{}h{}m", s / 3600, (s % 3600) / 60),
142        s if s >= 60 && s % 60 == 0 => format!("{}m", s / 60),
143        s if s >= 60 => format!("{}m{}s", s / 60, s % 60),
144        s => format!("{s}s"),
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn off_and_zero_are_the_same_interval() {
154        assert_eq!("off".parse::<Period>().unwrap().0, 0);
155        assert_eq!("OFF".parse::<Period>().unwrap().0, 0);
156        assert_eq!("0".parse::<Period>().unwrap().0, 0);
157    }
158
159    /// Both bounds are rejected at the command line so the operator sees
160    /// why, rather than getting a bare INVALID_ARGUMENT from the device.
161    #[test]
162    fn an_interval_outside_the_bounds_is_refused_with_its_reason() {
163        let error = "30".parse::<Period>().unwrap_err();
164        assert!(error.contains("20m"), "{error}");
165        assert!(error.contains("off"), "{error}");
166
167        let error = "90000".parse::<Period>().unwrap_err();
168        assert!(error.contains("24h"), "{error}");
169
170        // Both ends themselves are reachable.
171        assert_eq!(
172            "1200".parse::<Period>().unwrap().0,
173            MIN_AUTO_ANNOUNCE_INTERVAL_S
174        );
175        assert_eq!(
176            "86400".parse::<Period>().unwrap().0,
177            MAX_AUTO_ANNOUNCE_INTERVAL_S
178        );
179    }
180
181    #[test]
182    fn an_interval_reads_back_at_human_scale() {
183        assert_eq!(format_interval(0), "off");
184        assert_eq!(format_interval(14400), "every 14400 s (4h)");
185        assert_eq!(format_interval(3600), "every 3600 s (1h)");
186        assert_eq!(format_interval(1200), "every 1200 s (20m)");
187        assert_eq!(format_interval(5400), "every 5400 s (1h30m)");
188    }
189}