umshctl/command/
time.rs

1//! `time`: read, set, or clear the device's wall clock (`PROP_TIME`,
2//! `PROP_TZ_OFFSET`).
3//!
4//! The clock is not persisted — an epoch written to flash accumulates
5//! unbounded error while the device is off — so it comes back from a real
6//! source or not at all. The time zone *is* persisted: where a device is
7//! meant to be is known even when the time is not.
8
9use anyhow::{Result, bail};
10
11use umsh_gnss::DateTime;
12
13use super::persist;
14use crate::App;
15use crate::output::{field, note, subfield};
16
17#[derive(Debug, clap::Subcommand)]
18pub enum TimeOp {
19    /// Print the device's clock, in UTC and in its configured local time.
20    Show,
21    /// Set the clock from this host's clock.
22    Sync,
23    /// Set the clock to an explicit Unix second count.
24    Set {
25        #[arg(value_name = "UNIX-SECONDS")]
26        epoch: u32,
27    },
28    /// Return the device to not knowing what time it is. A device with a
29    /// screen stops showing a clock at all.
30    Clear,
31    /// Show or set the local time-zone offset.
32    Tz {
33        #[command(subcommand)]
34        op: Option<TzOp>,
35    },
36}
37
38#[derive(Debug, clap::Subcommand)]
39pub enum TzOp {
40    /// Print the configured offset.
41    Show,
42    /// Set the offset, as `±HH:MM`, `±HHMM`, `±H`, or a minute count.
43    Set {
44        #[arg(value_name = "±HH:MM|MINUTES", allow_hyphen_values = true)]
45        offset: TzOffsetArg,
46    },
47    /// Set the offset from this host's current time zone.
48    ///
49    /// It is the offset in effect right now that is copied, not the
50    /// zone: a device configured in July under daylight saving keeps
51    /// that offset into the winter, because the device has no zone
52    /// database to shift it with.
53    Sync,
54}
55
56/// A time-zone offset in minutes east of UTC.
57///
58/// Accepts the shapes people actually type — `-08:00`, `+0530`, `-8`,
59/// `330` — because the one thing a time-zone argument must not do is
60/// silently mean a different zone than it looks like.
61#[derive(Clone, Copy, Debug)]
62pub struct TzOffsetArg(pub i16);
63
64impl std::str::FromStr for TzOffsetArg {
65    type Err = String;
66
67    fn from_str(text: &str) -> Result<Self, Self::Err> {
68        let trimmed = text.trim();
69        if trimmed.is_empty() {
70            return Err("empty time-zone offset".to_string());
71        }
72        let (sign, rest) = match trimmed.as_bytes()[0] {
73            b'-' => (-1i32, &trimmed[1..]),
74            b'+' => (1, &trimmed[1..]),
75            _ => (1, trimmed),
76        };
77        let minutes = if let Some((hours, minutes)) = rest.split_once(':') {
78            parse_part(hours)? * 60 + parse_part(minutes)?
79        } else if rest.len() == 4 && rest.chars().all(|c| c.is_ascii_digit()) {
80            // `+0530`: hours and minutes run together, the shape RFC 3339
81            // and NMEA both use.
82            parse_part(&rest[..2])? * 60 + parse_part(&rest[2..])?
83        } else {
84            let value = parse_part(rest)?;
85            // A bare number small enough to be an hour count is one; the
86            // zones people name in whole hours vastly outnumber the ones
87            // anybody expresses as 60 minutes.
88            if value <= 14 { value * 60 } else { value }
89        };
90        let minutes = sign * minutes;
91        if !(-12 * 60..=14 * 60).contains(&minutes) {
92            return Err(format!(
93                "{trimmed} is outside the range of real time zones (UTC-12:00 to UTC+14:00)"
94            ));
95        }
96        Ok(Self(minutes as i16))
97    }
98}
99
100fn parse_part(text: &str) -> Result<i32, String> {
101    text.parse::<i32>()
102        .map_err(|_| format!("{text} is not a number"))
103}
104
105pub async fn run(app: &mut App, op: Option<TimeOp>) -> Result<()> {
106    let no_save = app.no_save;
107    let device = app.device()?;
108    match op.unwrap_or(TimeOp::Show) {
109        TimeOp::Show => {
110            let Some(time) = device.time().await? else {
111                bail!("device does not advertise CAP_TIME");
112            };
113            match time.epoch {
114                Some(epoch) => {
115                    field("time", format_utc(epoch));
116                    subfield("epoch", epoch);
117                    match DateTime::from_unix(epoch).shifted(time.tz_offset_min) {
118                        Some(local) => subfield(
119                            "local",
120                            format!("{} {}", format_civil(local), format_tz(time.tz_offset_min)),
121                        ),
122                        None => subfield("local", "outside the representable range"),
123                    }
124                }
125                None => {
126                    field("time", "not set");
127                    note("the device does not know what time it is and shows no clock");
128                    subfield("zone", format_tz(time.tz_offset_min));
129                }
130            }
131            return Ok(());
132        }
133        TimeOp::Sync => {
134            let now = host_epoch()?;
135            let stored = device.set_time(Some(now)).await?;
136            report_set(stored);
137        }
138        TimeOp::Set { epoch } => {
139            let stored = device.set_time(Some(epoch)).await?;
140            report_set(stored);
141        }
142        TimeOp::Clear => {
143            device.set_time(None).await?;
144            println!("time cleared: the device no longer knows what time it is");
145        }
146        TimeOp::Tz { op } => match op.unwrap_or(TzOp::Show) {
147            TzOp::Show => {
148                let Some(time) = device.time().await? else {
149                    bail!("device does not advertise CAP_TIME");
150                };
151                println!("time zone {}", format_tz(time.tz_offset_min));
152                return Ok(());
153            }
154            TzOp::Set { offset } => {
155                let stored = device.set_tz_offset(offset.0).await?;
156                println!("time zone {}", format_tz(stored));
157            }
158            TzOp::Sync => {
159                let offset = host_tz_offset()?;
160                let stored = device.set_tz_offset(offset).await?;
161                println!("time zone {} (from this host)", format_tz(stored));
162            }
163        },
164    }
165    persist(device, no_save).await
166}
167
168fn report_set(stored: Option<u32>) {
169    match stored {
170        Some(epoch) => println!("time {}", format_utc(epoch)),
171        None => println!("time not set (the device refused the value)"),
172    }
173}
174
175/// This host's wall clock as a Unix second count.
176fn host_epoch() -> Result<u32> {
177    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
178    u32::try_from(now.as_secs())
179        .map_err(|_| anyhow::anyhow!("this host's clock is outside the range PROP_TIME can carry"))
180}
181
182/// This host's current UTC offset, in minutes east.
183///
184/// Read through the C library rather than a date crate: `localtime_r`
185/// resolves `TZ`, the zone database, and today's daylight-saving state
186/// the same way every other program on the machine does, which is what
187/// "the system time zone" means to the person typing this.
188#[cfg(unix)]
189fn host_tz_offset() -> Result<i16> {
190    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
191    let seconds = libc::time_t::try_from(now.as_secs())
192        .map_err(|_| anyhow::anyhow!("this host's clock is outside the range time_t can carry"))?;
193    let mut parts: libc::tm = unsafe { std::mem::zeroed() };
194    // SAFETY: both pointers are to live, correctly typed locals, and
195    // `localtime_r` writes only through the second.
196    if unsafe { libc::localtime_r(&seconds, &mut parts) }.is_null() {
197        bail!("this host has no readable local time zone");
198    }
199    let minutes = parts.tm_gmtoff / 60;
200    i16::try_from(minutes)
201        .map_err(|_| anyhow::anyhow!("this host's UTC offset ({minutes} minutes) is not a zone"))
202}
203
204#[cfg(not(unix))]
205fn host_tz_offset() -> Result<i16> {
206    bail!("this platform exposes no system time zone; give the offset with `time tz set`")
207}
208
209fn format_utc(epoch: u32) -> String {
210    format!("{}Z", format_civil(DateTime::from_unix(epoch)))
211}
212
213fn format_civil(at: DateTime) -> String {
214    format!(
215        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
216        at.year, at.month, at.day, at.hour, at.minute, at.second
217    )
218}
219
220/// Render an offset the way it is written, so a mistyped one is visible
221/// as a zone rather than as a minute count nobody checks.
222pub fn format_tz(minutes: i16) -> String {
223    let sign = if minutes < 0 { '-' } else { '+' };
224    let magnitude = minutes.unsigned_abs();
225    format!("UTC{sign}{:02}:{:02}", magnitude / 60, magnitude % 60)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::str::FromStr;
232
233    #[test]
234    fn offsets_parse_in_every_shape_people_write_them() {
235        for (text, expected) in [
236            ("-08:00", -480),
237            ("+05:30", 330),
238            ("+0530", 330),
239            ("-0800", -480),
240            ("-8", -480),
241            ("+14", 840),
242            ("0", 0),
243            ("330", 330),
244            ("-330", -330),
245        ] {
246            assert_eq!(
247                TzOffsetArg::from_str(text).unwrap().0,
248                expected,
249                "parsing {text}"
250            );
251        }
252    }
253
254    #[test]
255    fn offsets_outside_the_real_range_are_refused() {
256        for text in ["+15:00", "-13:00", "900", "nonsense", ""] {
257            assert!(TzOffsetArg::from_str(text).is_err(), "accepted {text}");
258        }
259    }
260
261    #[test]
262    fn offsets_render_as_zones() {
263        assert_eq!(format_tz(0), "UTC+00:00");
264        assert_eq!(format_tz(-480), "UTC-08:00");
265        assert_eq!(format_tz(330), "UTC+05:30");
266        assert_eq!(format_tz(-30), "UTC-00:30");
267    }
268
269    #[test]
270    fn the_host_zone_is_a_real_zone() {
271        // Whatever the build machine's TZ is, the answer has to be one a
272        // device would accept — the same range `TzOffsetArg` enforces.
273        let minutes = host_tz_offset().expect("a host has a time zone");
274        assert!((-12 * 60..=14 * 60).contains(&minutes), "{minutes}");
275    }
276
277    #[test]
278    fn instants_render_as_civil_time() {
279        assert_eq!(format_utc(0), "1970-01-01 00:00:00Z");
280        assert_eq!(format_utc(1_000_000_000), "2001-09-09 01:46:40Z");
281    }
282}