1use 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 Show,
21 Sync,
23 Set {
25 #[arg(value_name = "UNIX-SECONDS")]
26 epoch: u32,
27 },
28 Clear,
31 Tz {
33 #[command(subcommand)]
34 op: Option<TzOp>,
35 },
36}
37
38#[derive(Debug, clap::Subcommand)]
39pub enum TzOp {
40 Show,
42 Set {
44 #[arg(value_name = "±HH:MM|MINUTES", allow_hyphen_values = true)]
45 offset: TzOffsetArg,
46 },
47 Sync,
54}
55
56#[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 parse_part(&rest[..2])? * 60 + parse_part(&rest[2..])?
83 } else {
84 let value = parse_part(rest)?;
85 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
175fn 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#[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 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
220pub 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 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}