umshctl/command/
gnss.rs

1//! `gnss`: report the receiver's state, switch it on or off, and set the
2//! policy for what is done with a fix (`PROP_GNSS_*`).
3//!
4//! The switch is persisted device-domain state, so a receiver left on
5//! comes back on. Off means the lowest power state the board can reach —
6//! on most of them the receiver is the largest continuous load there is.
7
8use anyhow::{Result, bail};
9
10use umsh::node::location::NodeLocation;
11use umsh::ulcp::GnssStatus;
12use umsh::ulcp_wire::gnss::FixKind;
13
14use super::persist;
15use crate::App;
16use crate::output::{field, note, subfield};
17
18#[derive(Debug, clap::Subcommand)]
19pub enum GnssOp {
20    /// Print the receiver state, the current fix, and the policy.
21    Status,
22    /// Power the receiver up and start looking for a fix.
23    On,
24    /// Power the receiver down to its lowest reachable state.
25    Off,
26    /// Refresh the advertised node identity's location from fixes, or
27    /// stop doing so.
28    IdentUpdate {
29        #[command(subcommand)]
30        op: ToggleOp,
31    },
32    /// Clamp the advertised location to this many bytes of precision
33    /// (1 coarsest, 7 finest).
34    IdentPrecision {
35        #[arg(value_name = "1-7")]
36        bytes: u8,
37    },
38    /// Whether receiver-derived time may set the wall clock. Turn it off
39    /// to keep a hand-set clock safe from a jammed or spoofed sky.
40    Trust {
41        #[command(subcommand)]
42        op: ToggleOp,
43    },
44}
45
46#[derive(Debug, clap::Subcommand)]
47pub enum ToggleOp {
48    On,
49    Off,
50}
51
52impl ToggleOp {
53    fn enabled(&self) -> bool {
54        matches!(self, Self::On)
55    }
56}
57
58pub async fn run(app: &mut App, op: Option<GnssOp>) -> Result<()> {
59    let no_save = app.no_save;
60    let device = app.device()?;
61    match op.unwrap_or(GnssOp::Status) {
62        GnssOp::Status => {
63            let Some(status) = device.gnss_status().await? else {
64                bail!("device does not advertise CAP_GNSS");
65            };
66            report(&status);
67            return Ok(());
68        }
69        GnssOp::On => {
70            let enabled = device.set_gnss_enabled(true).await?;
71            println!("gnss {}", if enabled { "on" } else { "off" });
72        }
73        GnssOp::Off => {
74            let enabled = device.set_gnss_enabled(false).await?;
75            println!("gnss {}", if enabled { "on" } else { "off" });
76        }
77        GnssOp::IdentUpdate { op } => {
78            let enabled = device.set_gnss_ident_update(op.enabled()).await?;
79            println!(
80                "gnss identity update {}",
81                if enabled { "on" } else { "off" }
82            );
83        }
84        GnssOp::IdentPrecision { bytes } => {
85            let stored = device.set_gnss_ident_precision(bytes).await?;
86            println!(
87                "gnss identity precision {stored} bytes ({})",
88                precision_cell(stored)
89            );
90        }
91        GnssOp::Trust { op } => {
92            let trust = device.set_gnss_time_trust(op.enabled()).await?;
93            println!("gnss time trust {}", if trust { "on" } else { "off" });
94            if !trust {
95                note("receiver time will not set the clock; set it with `time sync`");
96            }
97        }
98    }
99    persist(device, no_save).await
100}
101
102fn report(status: &GnssStatus) {
103    field("gnss", if status.enabled { "on" } else { "off" });
104    subfield(
105        "fix",
106        match status.fix.fix {
107            FixKind::None if status.enabled => "none (searching)",
108            FixKind::None => "none (receiver off)",
109            FixKind::TwoD => "2D",
110            FixKind::ThreeD => "3D",
111        },
112    );
113    match status.fix.sats_in_view {
114        Some(in_view) => subfield(
115            "satellites",
116            format!("{} used of {in_view} in view", status.fix.sats_used),
117        ),
118        None => subfield("satellites", format!("{} used", status.fix.sats_used)),
119    }
120    let location = status.fix.location();
121    if location.is_empty() {
122        subfield("location", "none");
123    } else {
124        subfield("location", format_location(location));
125        subfield("position", format_position(location));
126    }
127    match status.fix.altitude_m {
128        Some(meters) => subfield("altitude", format!("{meters} m")),
129        None => subfield("altitude", "unknown"),
130    }
131    match status.fix.accuracy_dm {
132        // Reported to a tenth because that is the resolution the property
133        // carries; it is an estimate scaled from dilution of precision,
134        // not a measured error bound.
135        Some(dm) => subfield(
136            "precision",
137            format!("~{}.{} m (estimated)", dm / 10, dm % 10),
138        ),
139        None => subfield("precision", "unknown"),
140    }
141    subfield(
142        "identity update",
143        match status.ident_update {
144            true => format!(
145                "on, clamped to {} bytes ({})",
146                status.ident_precision,
147                precision_cell(status.ident_precision)
148            ),
149            false => format!("off (would clamp to {} bytes)", status.ident_precision),
150        },
151    );
152    subfield(
153        "time trust",
154        match status.time_trust {
155            true => "on (fixes set the clock)",
156            false => "off (fixes never set the clock)",
157        },
158    );
159}
160
161/// Render the encoded location as hex, which is what it is: a grid code,
162/// not a coordinate pair. The cell size travels with it so the degrees
163/// on the next line are read as the cell they name.
164fn format_location(bytes: &[u8]) -> String {
165    let mut out = String::with_capacity(bytes.len() * 2 + 16);
166    for byte in bytes {
167        out.push_str(&format!("{byte:02x}"));
168    }
169    out.push_str(&format!(
170        " ({} bytes, {})",
171        bytes.len(),
172        precision_cell(bytes.len() as u8)
173    ));
174    out
175}
176
177/// The cell center in degrees. `NodeLocation`'s own rendering already
178/// matches its decimal places to the encoded precision, so the digits
179/// stop where the grid code stops saying anything.
180fn format_position(bytes: &[u8]) -> String {
181    format!("{} (lat, lon)", NodeLocation::from_bytes(bytes))
182}
183
184/// The approximate cell size one precision names, at the equator. What
185/// makes a precision meaningful to an operator is how big an area it
186/// discloses, and nothing else on the wire says.
187fn precision_cell(bytes: u8) -> &'static str {
188    match bytes {
189        1 => "~2500 km",
190        2 => "~156 km",
191        3 => "~9.8 km",
192        4 => "~610 m",
193        5 => "~38 m",
194        6 => "~2.4 m",
195        7 => "~15 cm",
196        _ => "out of range",
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn precision_names_the_area_it_discloses() {
206        assert_eq!(precision_cell(5), "~38 m");
207        assert_eq!(precision_cell(0), "out of range");
208        assert_eq!(precision_cell(8), "out of range");
209    }
210
211    #[test]
212    fn a_location_renders_as_the_grid_code_it_is() {
213        assert_eq!(
214            format_location(&[0x8a, 0x1f, 0x4c]),
215            "8a1f4c (3 bytes, ~9.8 km)"
216        );
217    }
218
219    #[test]
220    fn a_position_renders_as_degrees_at_the_encoded_precision() {
221        // Three bytes name a ~9.8 km cell, so it prints two decimals and
222        // stops — the same grid code as the test above.
223        assert_eq!(
224            format_position(&[0x8a, 0x1f, 0x4c]),
225            "0.90, 67.19 (lat, lon)"
226        );
227    }
228}