Skip to main content

aprs_decode/
weather.rs

1//! APRS weather data — both position-embedded and positionless reports.
2//!
3//! All fields use unit-aware newtypes with conversion methods so callers
4//! never need to know the native APRS wire units.
5
6use crate::error::AprsError;
7use crate::util::parse_bytes;
8
9// ─── Unit newtypes ────────────────────────────────────────────────────────────
10
11/// Wind direction in degrees (0–360; 0 means unknown/variable).
12#[derive(Debug, Copy, Clone, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[cfg_attr(feature = "serde", serde(transparent))]
15pub struct WindDirection(pub u16);
16
17impl WindDirection {
18    pub fn degrees(self) -> u16 {
19        self.0
20    }
21}
22
23/// Wind speed in statute miles per hour (APRS native unit).
24#[derive(Debug, Copy, Clone, PartialEq, Eq)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26#[cfg_attr(feature = "serde", serde(transparent))]
27pub struct WindSpeed(pub u16);
28
29impl WindSpeed {
30    pub fn mph(self) -> u16 {
31        self.0
32    }
33    pub fn knots(self) -> f32 {
34        self.0 as f32 * 0.868_976
35    }
36    pub fn kph(self) -> f32 {
37        self.0 as f32 * 1.609_344
38    }
39    pub fn m_per_s(self) -> f32 {
40        self.0 as f32 * 0.447_04
41    }
42}
43
44/// Temperature in degrees Fahrenheit (APRS native unit).
45#[derive(Debug, Copy, Clone, PartialEq, Eq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[cfg_attr(feature = "serde", serde(transparent))]
48pub struct Temperature(pub i16);
49
50impl Temperature {
51    pub fn fahrenheit(self) -> i16 {
52        self.0
53    }
54    pub fn celsius(self) -> f32 {
55        (self.0 as f32 - 32.0) * 5.0 / 9.0
56    }
57    pub fn kelvin(self) -> f32 {
58        self.celsius() + 273.15
59    }
60}
61
62/// Rainfall in hundredths of an inch (APRS native unit).
63#[derive(Debug, Copy, Clone, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65#[cfg_attr(feature = "serde", serde(transparent))]
66pub struct Rainfall(pub u16);
67
68impl Rainfall {
69    pub fn hundredths_inch(self) -> u16 {
70        self.0
71    }
72    pub fn inches(self) -> f32 {
73        self.0 as f32 / 100.0
74    }
75    pub fn mm(self) -> f32 {
76        self.inches() * 25.4
77    }
78}
79
80/// Relative humidity in percent (0–100; the wire value `00` means 100%).
81#[derive(Debug, Copy, Clone, PartialEq, Eq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83#[cfg_attr(feature = "serde", serde(transparent))]
84pub struct Humidity(pub u8);
85
86impl Humidity {
87    pub fn percent(self) -> u8 {
88        self.0
89    }
90}
91
92/// Barometric pressure in tenths of a millibar (= hundredths of hPa).
93///
94/// Example: 10250 → 1025.0 hPa.
95#[derive(Debug, Copy, Clone, PartialEq, Eq)]
96#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97#[cfg_attr(feature = "serde", serde(transparent))]
98pub struct Pressure(pub u32);
99
100impl Pressure {
101    pub fn tenths_mbar(self) -> u32 {
102        self.0
103    }
104    pub fn hpa(self) -> f32 {
105        self.0 as f32 / 10.0
106    }
107    pub fn mbar(self) -> f32 {
108        self.hpa()
109    }
110}
111
112/// Solar luminosity in watts per square metre.
113#[derive(Debug, Copy, Clone, PartialEq, Eq)]
114#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
115#[cfg_attr(feature = "serde", serde(transparent))]
116pub struct Luminosity(pub u16);
117
118impl Luminosity {
119    pub fn w_per_m2(self) -> u16 {
120        self.0
121    }
122}
123
124/// Snowfall in tenths of an inch over the last 24 hours.
125#[derive(Debug, Copy, Clone, PartialEq)]
126#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
127#[cfg_attr(feature = "serde", serde(transparent))]
128pub struct Snowfall(pub f32);
129
130impl Snowfall {
131    pub fn tenths_inch(self) -> f32 {
132        self.0
133    }
134    pub fn inches(self) -> f32 {
135        self.0 / 10.0
136    }
137    pub fn cm(self) -> f32 {
138        self.inches() * 2.54
139    }
140}
141
142// ─── AprsWeatherData ─────────────────────────────────────────────────────────
143
144/// Weather data fields, parsed from either a position packet (symbol `/_`)
145/// or a positionless weather report (DTI `_`).
146///
147/// Every field is an `Option` — not all transmitting stations send all fields.
148/// Native APRS units are preserved; use the conversion methods on each type
149/// to obtain SI or other values.
150#[derive(Debug, Clone, PartialEq, Default)]
151#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
152pub struct AprsWeatherData {
153    /// Wind direction (degrees, 0=unknown). Native: degrees.
154    pub wind_direction: Option<WindDirection>,
155    /// Sustained wind speed. Native: mph.
156    pub wind_speed: Option<WindSpeed>,
157    /// Peak wind gust in last 5 minutes. Native: mph.
158    pub wind_gust: Option<WindSpeed>,
159    /// Temperature. Native: °F.
160    pub temperature: Option<Temperature>,
161    /// Rainfall in the last hour. Native: hundredths of an inch.
162    pub rain_last_hour: Option<Rainfall>,
163    /// Rainfall in the last 24 hours. Native: hundredths of an inch.
164    pub rain_last_24h: Option<Rainfall>,
165    /// Rainfall since midnight. Native: hundredths of an inch.
166    pub rain_since_midnight: Option<Rainfall>,
167    /// Relative humidity 0–100 (wire `00` = 100%). Native: percent.
168    pub humidity: Option<Humidity>,
169    /// Barometric pressure. Native: tenths of a millibar.
170    pub barometric_pressure: Option<Pressure>,
171    /// Solar radiation. Native: W/m².
172    pub luminosity: Option<Luminosity>,
173    /// Snowfall in the last 24 hours. Native: tenths of an inch.
174    pub snow_last_24h: Option<Snowfall>,
175    /// Raw rain counter (implementation-specific).
176    pub raw_rain_counter: Option<u16>,
177}
178
179impl AprsWeatherData {
180    /// Parse the weather field block.
181    ///
182    /// Expected start: `DDD/SSS` (wind direction / wind speed), then lettered
183    /// single-char fields: `g` `t` `r` `p` `P` `h` `b` `l` `L` `s` `#`.
184    ///
185    /// Example: `220/004g005t077r000p000P000h50b09900`
186    pub fn parse(b: &[u8]) -> Result<Self, AprsError> {
187        if b.len() < 7 {
188            return Err(AprsError::TruncatedPacket {
189                expected: 7,
190                got: b.len(),
191            });
192        }
193
194        let wind_direction = parse_opt_u16(&b[0..3]).map(WindDirection);
195
196        if b[3] != b'/' {
197            return Err(AprsError::TruncatedPacket {
198                expected: 7,
199                got: b.len(),
200            });
201        }
202
203        let wind_speed = parse_opt_u16(&b[4..7]).map(WindSpeed);
204
205        let mut wind_gust = None;
206        let mut temperature = None;
207        let mut rain_last_hour = None;
208        let mut rain_last_24h = None;
209        let mut rain_since_midnight = None;
210        let mut humidity = None;
211        let mut barometric_pressure = None;
212        let mut luminosity = None;
213        let mut snow_last_24h = None;
214        let mut raw_rain_counter = None;
215
216        let mut i = 7usize;
217        while i < b.len() {
218            let key = b[i];
219            i += 1;
220            match key {
221                b'g' => {
222                    if i + 3 <= b.len() {
223                        wind_gust = parse_opt_u16(&b[i..i + 3]).map(WindSpeed);
224                        i += 3;
225                    }
226                }
227                b't' => {
228                    if i + 3 <= b.len() {
229                        temperature = parse_opt_i16(&b[i..i + 3]).map(Temperature);
230                        i += 3;
231                    }
232                }
233                b'r' => {
234                    if i + 3 <= b.len() {
235                        rain_last_hour = parse_opt_u16(&b[i..i + 3]).map(Rainfall);
236                        i += 3;
237                    }
238                }
239                b'p' => {
240                    if i + 3 <= b.len() {
241                        rain_last_24h = parse_opt_u16(&b[i..i + 3]).map(Rainfall);
242                        i += 3;
243                    }
244                }
245                b'P' => {
246                    if i + 3 <= b.len() {
247                        rain_since_midnight = parse_opt_u16(&b[i..i + 3]).map(Rainfall);
248                        i += 3;
249                    }
250                }
251                b'h' => {
252                    if i + 2 <= b.len() {
253                        humidity = parse_opt_u16(&b[i..i + 2])
254                            .map(|v| Humidity(if v == 0 { 100 } else { v as u8 }));
255                        i += 2;
256                    }
257                }
258                b'b' => {
259                    if i + 5 <= b.len() {
260                        barometric_pressure = parse_bytes::<u32>(&b[i..i + 5]).map(Pressure);
261                        i += 5;
262                    }
263                }
264                b'L' => {
265                    if i + 3 <= b.len() {
266                        luminosity = parse_opt_u16(&b[i..i + 3]).map(|v| Luminosity(v + 1000));
267                        i += 3;
268                    }
269                }
270                b'l' => {
271                    if i + 3 <= b.len() {
272                        luminosity = parse_opt_u16(&b[i..i + 3]).map(Luminosity);
273                        i += 3;
274                    }
275                }
276                b's' => {
277                    if i + 3 <= b.len() {
278                        snow_last_24h =
279                            parse_opt_u16(&b[i..i + 3]).map(|v| Snowfall(v as f32 / 10.0));
280                        i += 3;
281                    }
282                }
283                b'#' => {
284                    if i + 3 <= b.len() {
285                        raw_rain_counter = parse_opt_u16(&b[i..i + 3]);
286                        i += 3;
287                    }
288                }
289                _ => break, // unknown field — stop here, rest is comment
290            }
291        }
292
293        Ok(Self {
294            wind_direction,
295            wind_speed,
296            wind_gust,
297            temperature,
298            rain_last_hour,
299            rain_last_24h,
300            rain_since_midnight,
301            humidity,
302            barometric_pressure,
303            luminosity,
304            snow_last_24h,
305            raw_rain_counter,
306        })
307    }
308
309    /// Encode weather fields to bytes (without any header).
310    pub fn encode(&self, out: &mut Vec<u8>) {
311        match self.wind_direction {
312            Some(d) => out.extend_from_slice(format!("{:03}", d.degrees()).as_bytes()),
313            None => out.extend_from_slice(b"..."),
314        }
315        out.push(b'/');
316        match self.wind_speed {
317            Some(s) => out.extend_from_slice(format!("{:03}", s.mph()).as_bytes()),
318            None => out.extend_from_slice(b"..."),
319        }
320        if let Some(g) = self.wind_gust {
321            out.extend_from_slice(format!("g{:03}", g.mph()).as_bytes());
322        }
323        if let Some(t) = self.temperature {
324            out.extend_from_slice(format!("t{:03}", t.fahrenheit()).as_bytes());
325        }
326        if let Some(r) = self.rain_last_hour {
327            out.extend_from_slice(format!("r{:03}", r.hundredths_inch()).as_bytes());
328        }
329        if let Some(p) = self.rain_last_24h {
330            out.extend_from_slice(format!("p{:03}", p.hundredths_inch()).as_bytes());
331        }
332        if let Some(p) = self.rain_since_midnight {
333            out.extend_from_slice(format!("P{:03}", p.hundredths_inch()).as_bytes());
334        }
335        if let Some(h) = self.humidity {
336            let v = if h.percent() == 100 { 0 } else { h.percent() };
337            out.extend_from_slice(format!("h{:02}", v).as_bytes());
338        }
339        if let Some(b_val) = self.barometric_pressure {
340            out.extend_from_slice(format!("b{:05}", b_val.tenths_mbar()).as_bytes());
341        }
342        if let Some(l) = self.luminosity {
343            if l.w_per_m2() >= 1000 {
344                out.extend_from_slice(format!("L{:03}", l.w_per_m2() - 1000).as_bytes());
345            } else {
346                out.extend_from_slice(format!("l{:03}", l.w_per_m2()).as_bytes());
347            }
348        }
349        if let Some(s) = self.snow_last_24h {
350            out.extend_from_slice(format!("s{:03}", (s.tenths_inch() * 10.0) as u16).as_bytes());
351        }
352        if let Some(r) = self.raw_rain_counter {
353            out.extend_from_slice(format!("#{:03}", r).as_bytes());
354        }
355    }
356}
357
358// ─── AprsPositionlessWeather ──────────────────────────────────────────────────
359
360/// A positionless weather report. DTI: `_`.
361///
362/// Format: `_MMDDHHMM` (local-time timestamp) + weather fields.
363/// The timestamp is stored as raw bytes since the MMDDHHMM format is distinct
364/// from the DDHHMM/HHMMSS formats used in other APRS packets.
365#[derive(Debug, Clone, PartialEq)]
366#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
367pub struct AprsPositionlessWeather {
368    /// 8-byte MMDDHHMM timestamp in local time.
369    pub timestamp: Vec<u8>,
370    pub weather: AprsWeatherData,
371    pub comment: Vec<u8>,
372}
373
374impl AprsPositionlessWeather {
375    /// Decode from the information field (including the leading `_` DTI byte).
376    pub(crate) fn parse(info: &[u8]) -> Result<Self, AprsError> {
377        // info[0] = '_', info[1..9] = MMDDHHMM (8 bytes), info[9..] = weather data
378        if info.len() < 9 {
379            return Err(AprsError::TruncatedPacket {
380                expected: 9,
381                got: info.len(),
382            });
383        }
384        let timestamp = info[1..9].to_vec();
385        let weather_bytes = &info[9..];
386        let weather = AprsWeatherData::parse(weather_bytes)?;
387        Ok(Self {
388            timestamp,
389            weather,
390            comment: vec![],
391        })
392    }
393
394    pub fn encode(&self) -> Vec<u8> {
395        let mut out = vec![b'_'];
396        out.extend_from_slice(&self.timestamp);
397        self.weather.encode(&mut out);
398        out.extend_from_slice(&self.comment);
399        out
400    }
401}
402
403// ─── Helpers ─────────────────────────────────────────────────────────────────
404
405/// Parse a 2–5 byte field as u16, returning None for all-spaces or all-dots.
406fn parse_opt_u16(b: &[u8]) -> Option<u16> {
407    if b.iter().all(|&c| c == b'.' || c == b' ') {
408        return None;
409    }
410    parse_bytes(b)
411}
412
413/// Parse a 3-byte field as i16 (handles negative temperatures like `-10`).
414fn parse_opt_i16(b: &[u8]) -> Option<i16> {
415    if b.iter().all(|&c| c == b'.' || c == b' ') {
416        return None;
417    }
418    parse_bytes(b)
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn parse_full_weather() {
427        let wx = AprsWeatherData::parse(b"220/004g005t077r000p000P000h50b09900").unwrap();
428        assert_eq!(wx.wind_direction.unwrap().degrees(), 220);
429        assert_eq!(wx.wind_speed.unwrap().mph(), 4);
430        assert_eq!(wx.wind_gust.unwrap().mph(), 5);
431        assert_eq!(wx.temperature.unwrap().fahrenheit(), 77);
432        assert_eq!(wx.rain_last_hour.unwrap().hundredths_inch(), 0);
433        assert_eq!(wx.humidity.unwrap().percent(), 50);
434        assert_eq!(wx.barometric_pressure.unwrap().tenths_mbar(), 9900);
435    }
436
437    #[test]
438    fn temperature_conversion() {
439        let t = Temperature(32); // 32°F = 0°C
440        assert!((t.celsius() - 0.0).abs() < 0.01);
441        let t = Temperature(212); // 212°F = 100°C
442        assert!((t.celsius() - 100.0).abs() < 0.01);
443    }
444
445    #[test]
446    fn wind_speed_conversion() {
447        let s = WindSpeed(10); // 10 mph
448        assert!((s.knots() - 8.68976).abs() < 0.001);
449        assert!((s.kph() - 16.09344).abs() < 0.001);
450    }
451
452    #[test]
453    fn pressure_conversion() {
454        let p = Pressure(10250);
455        assert!((p.hpa() - 1025.0).abs() < 0.01);
456    }
457
458    #[test]
459    fn rainfall_conversion() {
460        let r = Rainfall(100); // 100 hundredths = 1.00 inch
461        assert!((r.inches() - 1.0).abs() < 0.001);
462        assert!((r.mm() - 25.4).abs() < 0.01);
463    }
464
465    #[test]
466    fn humidity_100_encoded_as_00() {
467        let wx = AprsWeatherData::parse(b"000/000h00").unwrap();
468        assert_eq!(wx.humidity.unwrap().percent(), 100);
469    }
470
471    #[test]
472    fn negative_temperature() {
473        let wx = AprsWeatherData::parse(b"000/000g000t-10").unwrap();
474        assert_eq!(wx.temperature.unwrap().fahrenheit(), -10);
475    }
476
477    #[test]
478    fn luminosity_high() {
479        let wx = AprsWeatherData::parse(b"000/000L042").unwrap();
480        assert_eq!(wx.luminosity.unwrap().w_per_m2(), 1042);
481    }
482
483    #[test]
484    fn unknown_fields_stop_parsing() {
485        // After an unknown field letter, parsing stops; rest is treated as comment
486        let wx = AprsWeatherData::parse(b"220/004g005XUNKNOWN").unwrap();
487        assert_eq!(wx.wind_direction.unwrap().degrees(), 220);
488        assert_eq!(wx.wind_gust.unwrap().mph(), 5);
489        assert!(wx.temperature.is_none());
490    }
491
492    #[test]
493    fn encode_round_trip() {
494        let raw = b"220/004g005t077r000p000P000h50b09900";
495        let wx = AprsWeatherData::parse(raw).unwrap();
496        let mut out = Vec::new();
497        wx.encode(&mut out);
498        assert_eq!(out.as_slice(), raw.as_slice());
499    }
500
501    #[test]
502    fn positionless_parse() {
503        let pw = AprsPositionlessWeather::parse(b"_10071820220/004g005t077").unwrap();
504        assert_eq!(pw.timestamp, b"10071820");
505        assert_eq!(pw.weather.wind_direction.unwrap().degrees(), 220);
506        assert_eq!(pw.weather.temperature.unwrap().fahrenheit(), 77);
507    }
508
509    #[test]
510    fn positionless_encode_round_trip() {
511        let raw = b"_10071820220/004g005t077";
512        let pw = AprsPositionlessWeather::parse(raw).unwrap();
513        assert_eq!(pw.encode().as_slice(), raw.as_slice());
514    }
515}