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