Skip to main content

aprs_decode/types/
timestamp.rs

1use crate::error::AprsError;
2use crate::util::parse_bytes;
3
4/// An APRS timestamp parsed from the packet header.
5///
6/// APRS101 defines three formats; the local-time format (`/`) is represented
7/// as `Unsupported` since it is deprecated and ambiguous.
8#[derive(Debug, Clone, PartialEq, Eq)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub enum Timestamp {
11    /// Day-of-month (1–31), Hour (0–23), Minute (0–59) in UTC. Suffix `z`.
12    Ddhhmm(u8, u8, u8),
13    /// Hour (0–23), Minute (0–59), Second (0–59) in UTC. Suffix `h`.
14    Hhmmss(u8, u8, u8),
15    /// Local-time format (deprecated, suffix `/`). Stored as raw bytes.
16    Unsupported(Vec<u8>),
17}
18
19impl Timestamp {
20    /// Parse a 7-byte timestamp field.
21    pub fn parse(b: &[u8]) -> Result<Self, AprsError> {
22        if b.len() != 7 {
23            return Err(AprsError::InvalidTimestampFormat { raw: b.to_vec() });
24        }
25        if b[6] == b'/' {
26            return Ok(Timestamp::Unsupported(b.to_vec()));
27        }
28        let f1: u8 = parse_bytes(&b[0..2])
29            .ok_or_else(|| AprsError::InvalidTimestampFormat { raw: b.to_vec() })?;
30        let f2: u8 = parse_bytes(&b[2..4])
31            .ok_or_else(|| AprsError::InvalidTimestampFormat { raw: b.to_vec() })?;
32        let f3: u8 = parse_bytes(&b[4..6])
33            .ok_or_else(|| AprsError::InvalidTimestampFormat { raw: b.to_vec() })?;
34
35        match b[6] {
36            b'z' | b'Z' => {
37                validate_ddhhmm(f1, f2, f3, b)?;
38                Ok(Timestamp::Ddhhmm(f1, f2, f3))
39            }
40            b'h' | b'H' => {
41                validate_hhmmss(f1, f2, f3, b)?;
42                Ok(Timestamp::Hhmmss(f1, f2, f3))
43            }
44            // Some trackers (e.g. certain MFJ/APMI firmwares) emit a non-standard
45            // designator such as `#` in the 7th byte. The `@`/`/` DTI guarantees the
46            // first 7 bytes are the timestamp field, so preserve the raw bytes rather
47            // than failing the whole packet — the position that follows is well-formed.
48            _ => Ok(Timestamp::Unsupported(b.to_vec())),
49        }
50    }
51
52    /// Write the timestamp in its original wire format.
53    pub fn encode(&self, out: &mut Vec<u8>) {
54        match self {
55            Timestamp::Ddhhmm(d, h, m) => {
56                out.extend_from_slice(format!("{:02}{:02}{:02}z", d, h, m).as_bytes());
57            }
58            Timestamp::Hhmmss(h, m, s) => {
59                out.extend_from_slice(format!("{:02}{:02}{:02}h", h, m, s).as_bytes());
60            }
61            Timestamp::Unsupported(raw) => out.extend_from_slice(raw),
62        }
63    }
64}
65
66fn validate_ddhhmm(day: u8, hour: u8, minute: u8, raw: &[u8]) -> Result<(), AprsError> {
67    if day == 0 || day > 31 {
68        return Err(AprsError::TimestampDayOutOfRange { day });
69    }
70    if hour > 23 {
71        return Err(AprsError::TimestampHourOutOfRange { hour });
72    }
73    if minute > 59 {
74        return Err(AprsError::TimestampMinuteOutOfRange { minute });
75    }
76    let _ = raw; // suppress unused warning
77    Ok(())
78}
79
80fn validate_hhmmss(hour: u8, minute: u8, second: u8, raw: &[u8]) -> Result<(), AprsError> {
81    if hour > 23 {
82        return Err(AprsError::TimestampHourOutOfRange { hour });
83    }
84    if minute > 59 {
85        return Err(AprsError::TimestampMinuteOutOfRange { minute });
86    }
87    if second > 59 {
88        return Err(AprsError::TimestampSecondOutOfRange { second });
89    }
90    let _ = raw;
91    Ok(())
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn parse_ddhhmm() {
100        assert_eq!(
101            Timestamp::parse(b"092345z").unwrap(),
102            Timestamp::Ddhhmm(9, 23, 45)
103        );
104    }
105
106    #[test]
107    fn parse_hhmmss() {
108        assert_eq!(
109            Timestamp::parse(b"074849h").unwrap(),
110            Timestamp::Hhmmss(7, 48, 49)
111        );
112    }
113
114    #[test]
115    fn parse_local_unsupported() {
116        assert!(matches!(
117            Timestamp::parse(b"092345/").unwrap(),
118            Timestamp::Unsupported(_)
119        ));
120    }
121
122    #[test]
123    fn parse_nonstandard_designator_preserved() {
124        // A `#` designator (seen from some trackers) is preserved raw rather than
125        // failing, so the well-formed position that follows can still be parsed.
126        let ts = Timestamp::parse(b"291500#").unwrap();
127        assert!(matches!(ts, Timestamp::Unsupported(_)));
128        let mut out = Vec::new();
129        ts.encode(&mut out);
130        assert_eq!(out, b"291500#");
131    }
132
133    #[test]
134    fn day_zero_invalid() {
135        assert!(Timestamp::parse(b"002345z").is_err());
136    }
137
138    #[test]
139    fn day_32_invalid() {
140        assert!(Timestamp::parse(b"322345z").is_err());
141    }
142
143    #[test]
144    fn hour_24_invalid() {
145        assert!(Timestamp::parse(b"092445z").is_err());
146    }
147
148    #[test]
149    fn minute_60_invalid() {
150        assert!(Timestamp::parse(b"092360z").is_err());
151    }
152
153    #[test]
154    fn second_60_invalid() {
155        assert!(Timestamp::parse(b"074860h").is_err());
156    }
157
158    #[test]
159    fn encode_round_trip_ddhhmm() {
160        let ts = Timestamp::Ddhhmm(9, 23, 45);
161        let mut out = Vec::new();
162        ts.encode(&mut out);
163        assert_eq!(Timestamp::parse(&out).unwrap(), ts);
164    }
165
166    #[test]
167    fn encode_round_trip_hhmmss() {
168        let ts = Timestamp::Hhmmss(7, 48, 49);
169        let mut out = Vec::new();
170        ts.encode(&mut out);
171        assert_eq!(Timestamp::parse(&out).unwrap(), ts);
172    }
173}