aprs/weather.rs
1//! APRS weather reports (APRS 1.0.1 ch. 12).
2//!
3//! Covers both standalone positionless weather frames (data type `_`)
4//! and weather data embedded in a position report when the symbol code
5//! is `_` (weather station).
6
7use crate::error::AprsError;
8
9/// An APRS weather report.
10///
11/// Weather data can be embedded in a position report or sent as a
12/// standalone positionless weather report (data type `_`). The TH-D75
13/// displays weather station data in the station list.
14///
15/// All fields are optional — weather stations may report any subset.
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct AprsWeather {
18 /// Wind direction in degrees (0-360).
19 pub wind_direction: Option<u16>,
20 /// Wind speed in mph.
21 pub wind_speed: Option<u16>,
22 /// Wind gust in mph (peak in last 5 minutes).
23 pub wind_gust: Option<u16>,
24 /// Temperature in degrees Fahrenheit.
25 pub temperature: Option<i16>,
26 /// Rainfall in last hour (hundredths of an inch).
27 pub rain_1h: Option<u16>,
28 /// Rainfall in last 24 hours (hundredths of an inch).
29 pub rain_24h: Option<u16>,
30 /// Rainfall since midnight (hundredths of an inch).
31 pub rain_since_midnight: Option<u16>,
32 /// Humidity in percent (1-100). Raw APRS `00` is converted to 100.
33 pub humidity: Option<u8>,
34 /// Barometric pressure in tenths of millibars/hPa.
35 pub pressure: Option<u32>,
36}
37
38/// Try to extract weather data embedded in a position report's comment.
39///
40/// Per APRS 1.0.1 §12.1, a "complete weather report" is a position report
41/// with symbol code `_` (weather station) whose comment begins with the
42/// CSE/SPD extension format `DDD/SSS` encoding wind direction and speed,
43/// followed by the remaining weather fields (`gGGG tTTT rRRR …`) in the
44/// standard order.
45///
46/// Returns `None` if the symbol is not `_` or the comment does not start
47/// with a valid `DDD/SSS` extension.
48pub fn extract_position_weather(symbol_code: char, comment: &str) -> Option<AprsWeather> {
49 if symbol_code != '_' {
50 return None;
51 }
52 let bytes = comment.as_bytes();
53 let header = bytes.get(..7)?;
54 if header.get(3) != Some(&b'/') {
55 return None;
56 }
57 let dir_bytes = header.get(..3)?;
58 let spd_bytes = header.get(4..7)?;
59 if !dir_bytes.iter().all(u8::is_ascii_digit) || !spd_bytes.iter().all(u8::is_ascii_digit) {
60 return None;
61 }
62 let wind_dir: u16 = comment.get(..3)?.parse().ok()?;
63 let wind_spd: u16 = comment.get(4..7)?.parse().ok()?;
64 let tail = bytes.get(7..)?;
65 let mut wx = parse_weather_fields(tail);
66 wx.wind_direction = Some(wind_dir);
67 wx.wind_speed = Some(wind_spd);
68 Some(wx)
69}
70
71/// Parse a positionless APRS weather report (`_MMDDHHMMdata`).
72///
73/// Weather data uses single-letter field tags followed by fixed-width
74/// numeric values. Common fields:
75/// - `c` = wind direction (3 digits, degrees)
76/// - `s` = wind speed (3 digits, mph)
77/// - `g` = gust (3 digits, mph)
78/// - `t` = temperature (3 digits, Fahrenheit, may be negative)
79/// - `r` = rain last hour (3 digits, hundredths of inch)
80/// - `p` = rain last 24h (3 digits, hundredths of inch)
81/// - `P` = rain since midnight (3 digits, hundredths of inch)
82/// - `h` = humidity (2 digits, 00=100%)
83/// - `b` = barometric pressure (5 digits, tenths of mbar)
84///
85/// # Errors
86///
87/// Returns [`AprsError::InvalidFormat`] if the info field does not begin
88/// with the `_` data type identifier.
89pub fn parse_aprs_weather_positionless(info: &[u8]) -> Result<AprsWeather, AprsError> {
90 if info.first() != Some(&b'_') {
91 return Err(AprsError::InvalidFormat);
92 }
93 // Skip _ and 8-char timestamp (MMDDHHMM)
94 let data = info.get(9..).unwrap_or(&[]);
95 Ok(parse_weather_fields(data))
96}
97
98/// Parse APRS weather data fields from a byte slice.
99///
100/// Per APRS 1.0.1 §12.2, weather fields are a contiguous sequence of
101/// `<tag><value>` pairs in a **fixed order** (wind direction, wind speed,
102/// gust, temperature, rain 1h, rain 24h, rain since midnight, humidity,
103/// pressure, luminosity). Each field is optional and, if present, uses a
104/// fixed-width decimal value. A value of all dots or spaces means the
105/// station has no data for that field.
106///
107/// The parser walks the buffer from the start, consumes a known tag +
108/// value pair, and advances. It stops on the first unknown byte, leaving
109/// any trailing comment / station-type suffix alone.
110///
111/// This is strictly more correct than a `find()`-based scan, which would
112/// false-match tag letters appearing inside comment text (e.g. `"canada"`
113/// matching `c` for wind direction).
114///
115/// Private by design: callers outside this crate should use
116/// [`parse_aprs_weather_positionless`] (which validates the leading
117/// `_` + 8-byte timestamp and then delegates here) to avoid mistaking
118/// non-weather bytes for weather data.
119fn parse_weather_fields(data: &[u8]) -> AprsWeather {
120 let mut wx = AprsWeather::default();
121 let mut i = 0;
122 while let Some(&tag) = data.get(i) {
123 let width = match tag {
124 b'c' | b's' | b'g' | b't' | b'r' | b'p' | b'P' | b'L' | b'l' => 3,
125 b'h' => 2,
126 b'b' => 5,
127 // Unknown byte — assume start of comment / type suffix.
128 _ => break,
129 };
130 let Some(val_bytes) = data.get(i + 1..i + 1 + width) else {
131 break;
132 };
133 let parsed_i32 = parse_weather_value(val_bytes);
134 match tag {
135 b'c' => {
136 // Wind direction: 000 is the "true North / no data"
137 // convention; most stations encode 360 as 000.
138 wx.wind_direction = parsed_i32.and_then(convert_u16);
139 }
140 b's' => wx.wind_speed = parsed_i32.and_then(convert_u16),
141 b'g' => wx.wind_gust = parsed_i32.and_then(convert_u16),
142 b't' => wx.temperature = parsed_i32.and_then(convert_i16),
143 b'r' => wx.rain_1h = parsed_i32.and_then(convert_u16),
144 b'p' => wx.rain_24h = parsed_i32.and_then(convert_u16),
145 b'P' => wx.rain_since_midnight = parsed_i32.and_then(convert_u16),
146 b'h' => {
147 // APRS encodes humidity 100% as "00".
148 wx.humidity = parsed_i32.and_then(|v| {
149 if v == 0 {
150 Some(100)
151 } else {
152 u8::try_from(v).ok()
153 }
154 });
155 }
156 b'b' => wx.pressure = parsed_i32.and_then(|v| u32::try_from(v).ok()),
157 // Luminosity (L/l): not yet represented in AprsWeather.
158 b'L' | b'l' => {}
159 // The match above ensures only the tag bytes we set a width
160 // for reach here; other bytes cause the loop to break above.
161 _ => break,
162 }
163 i += 1 + width;
164 }
165 wx
166}
167
168/// Parse a fixed-width weather field value. Returns `None` if the bytes
169/// are a "no data" placeholder (dots or spaces) or unparseable.
170fn parse_weather_value(bytes: &[u8]) -> Option<i32> {
171 if bytes.iter().all(|&b| b == b'.' || b == b' ') {
172 return None;
173 }
174 let s = std::str::from_utf8(bytes).ok()?;
175 s.trim().parse().ok()
176}
177
178/// Lossless widening from `i32` to `u16` for weather values.
179#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
180const fn convert_u16(v: i32) -> Option<u16> {
181 if v < 0 || v > u16::MAX as i32 {
182 None
183 } else {
184 Some(v as u16)
185 }
186}
187
188/// Lossless widening from `i32` to `i16` for signed weather values.
189#[allow(clippy::cast_possible_truncation)]
190const fn convert_i16(v: i32) -> Option<i16> {
191 if v < i16::MIN as i32 || v > i16::MAX as i32 {
192 None
193 } else {
194 Some(v as i16)
195 }
196}
197
198// ---------------------------------------------------------------------------
199// Tests
200// ---------------------------------------------------------------------------
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 type TestResult = Result<(), Box<dyn std::error::Error>>;
207
208 #[test]
209 fn parse_weather_positionless_full() -> TestResult {
210 let info = b"_01011234c180s005g010t075r001p010P020h55b10135";
211 let wx = parse_aprs_weather_positionless(info)?;
212 assert_eq!(wx.wind_direction, Some(180));
213 assert_eq!(wx.wind_speed, Some(5));
214 assert_eq!(wx.wind_gust, Some(10));
215 assert_eq!(wx.temperature, Some(75));
216 assert_eq!(wx.rain_1h, Some(1));
217 assert_eq!(wx.rain_24h, Some(10));
218 assert_eq!(wx.rain_since_midnight, Some(20));
219 assert_eq!(wx.humidity, Some(55));
220 assert_eq!(wx.pressure, Some(10135));
221 Ok(())
222 }
223
224 #[test]
225 fn parse_weather_missing_fields() -> TestResult {
226 let info = b"_01011234c...s...t072";
227 let wx = parse_aprs_weather_positionless(info)?;
228 assert_eq!(wx.wind_direction, None); // dots = missing
229 assert_eq!(wx.wind_speed, None);
230 assert_eq!(wx.temperature, Some(72));
231 Ok(())
232 }
233
234 #[test]
235 fn parse_weather_humidity_zero_means_100() -> TestResult {
236 let info = b"_01011234h00";
237 let wx = parse_aprs_weather_positionless(info)?;
238 assert_eq!(wx.humidity, Some(100));
239 Ok(())
240 }
241
242 #[test]
243 fn parse_weather_stops_on_comment_text() -> TestResult {
244 // Regression: the old find('c')-based parser would match 'c' in
245 // the word "canada" inside a comment. The new position-based
246 // parser stops on the first unknown byte.
247 let info = b"_01011234t072canada";
248 let wx = parse_aprs_weather_positionless(info)?;
249 assert_eq!(wx.temperature, Some(72));
250 assert_eq!(wx.wind_direction, None); // must NOT be Some(nad)
251 Ok(())
252 }
253
254 #[test]
255 fn parse_weather_fields_in_order_with_gaps() {
256 // Temperature only — other fields omitted entirely.
257 let wx = parse_weather_fields(b"t072");
258 assert_eq!(wx.temperature, Some(72));
259 assert_eq!(wx.wind_direction, None);
260 }
261
262 #[test]
263 fn parse_weather_rejects_trailing_garbage() {
264 // The old parser would still find 'b' anywhere. The new parser
265 // stops at the first unknown byte.
266 let wx = parse_weather_fields(b"t072 b is not pressure");
267 assert_eq!(wx.temperature, Some(72));
268 assert_eq!(wx.pressure, None);
269 }
270}