aprs/
station_list.rs

1//! APRS station list tracker.
2//!
3//! Maintains a list of APRS stations heard on the network, with their
4//! latest position, status, weather data, packet count, and digipeater
5//! path. Supports spatial queries via the haversine formula.
6//!
7//! # Time handling
8//!
9//! Per the crate-level convention, this module is sans-io and never calls
10//! `std::time::Instant::now()` internally. Every stateful method that
11//! reads the clock accepts a `now: Instant` parameter; callers (typically
12//! the tokio shell) read the wall clock once per iteration and thread
13//! it down.
14
15use std::collections::HashMap;
16use std::time::{Duration, Instant};
17
18use crate::packet::AprsData;
19use crate::position::AprsPosition;
20use crate::weather::AprsWeather;
21
22/// Earth's mean radius in kilometres (WGS-84 volumetric mean).
23const EARTH_RADIUS_KM: f64 = 6_371.0;
24
25/// Tracks APRS stations heard on the network.
26#[derive(Debug)]
27pub struct StationList {
28    /// Stations indexed by callsign.
29    stations: HashMap<String, StationEntry>,
30    /// Maximum number of entries to keep.
31    max_entries: usize,
32    /// Maximum age before a station is considered expired.
33    max_age: Duration,
34}
35
36/// A single station's latest state.
37#[derive(Debug, Clone)]
38pub struct StationEntry {
39    /// Station callsign (key).
40    pub callsign: String,
41    /// When this station was last heard.
42    pub last_heard: Instant,
43    /// Most recent position.
44    pub position: Option<AprsPosition>,
45    /// Most recent status text.
46    pub last_status: Option<String>,
47    /// Most recent weather report.
48    pub last_weather: Option<AprsWeather>,
49    /// Total number of packets received from this station.
50    pub packet_count: u32,
51    /// Digipeater path from the most recent packet.
52    pub last_path: Vec<String>,
53}
54
55impl StationList {
56    /// Create a new station list with the given capacity and age limits.
57    #[must_use]
58    #[allow(clippy::missing_const_for_fn)] // HashMap::new() is not const
59    pub fn new(max_entries: usize, max_age: Duration) -> Self {
60        Self {
61            stations: HashMap::new(),
62            max_entries,
63            max_age,
64        }
65    }
66
67    /// Update the station list from a parsed APRS packet.
68    ///
69    /// Creates a new entry if the station has not been seen before, or
70    /// updates the existing entry with fresh data. The `now` parameter
71    /// stamps the entry's `last_heard` field — callers in the tokio
72    /// shell read the wall clock once per iteration and thread it down.
73    pub fn update(&mut self, source: &str, data: &AprsData, path: &[String], now: Instant) {
74        let entry = self
75            .stations
76            .entry(source.to_owned())
77            .or_insert_with(|| StationEntry {
78                callsign: source.to_owned(),
79                last_heard: now,
80                position: None,
81                last_status: None,
82                last_weather: None,
83                packet_count: 0,
84                last_path: Vec::new(),
85            });
86
87        entry.last_heard = now;
88        entry.packet_count = entry.packet_count.saturating_add(1);
89        entry.last_path = path.to_vec();
90
91        match data {
92            AprsData::Position(pos) => {
93                // A weather-station position (symbol `_`) carries embedded
94                // wx data too — record both.
95                if let Some(ref wx) = pos.weather {
96                    entry.last_weather = Some(wx.clone());
97                }
98                entry.position = Some(pos.clone());
99            }
100            AprsData::Status(status) => {
101                entry.last_status = Some(status.text.clone());
102            }
103            AprsData::Message(_)
104            | AprsData::Object(_)
105            | AprsData::Item(_)
106            | AprsData::Telemetry(_)
107            | AprsData::Query(_)
108            | AprsData::ThirdParty { .. }
109            | AprsData::Grid(_)
110            | AprsData::RawGps(_)
111            | AprsData::StationCapabilities(_)
112            | AprsData::AgreloDfJr(_)
113            | AprsData::UserDefined { .. }
114            | AprsData::InvalidOrTest(_) => {
115                // These frame types don't update the station's own
116                // position or status.
117            }
118            AprsData::Weather(wx) => {
119                entry.last_weather = Some(wx.clone());
120            }
121        }
122
123        // Evict oldest entry if over capacity.
124        if self.stations.len() > self.max_entries {
125            self.evict_oldest();
126        }
127    }
128
129    /// Get a station entry by callsign.
130    #[must_use]
131    pub fn get(&self, callsign: &str) -> Option<&StationEntry> {
132        self.stations.get(callsign)
133    }
134
135    /// Get all stations sorted by last heard (most recent first).
136    #[must_use]
137    pub fn recent(&self) -> Vec<&StationEntry> {
138        let mut entries: Vec<&StationEntry> = self.stations.values().collect();
139        entries.sort_by(|a, b| b.last_heard.cmp(&a.last_heard));
140        entries
141    }
142
143    /// Get stations within a radius (in km) of a position.
144    ///
145    /// Uses the haversine formula for great-circle distance calculation.
146    /// Only stations with a known position are considered.
147    #[must_use]
148    pub fn nearby(&self, lat: f64, lon: f64, radius_km: f64) -> Vec<&StationEntry> {
149        self.stations
150            .values()
151            .filter(|e| {
152                e.position.as_ref().is_some_and(|pos| {
153                    haversine_km(lat, lon, pos.latitude, pos.longitude) <= radius_km
154                })
155            })
156            .collect()
157    }
158
159    /// Remove expired entries (older than `max_age`).
160    ///
161    /// The `now` parameter is compared against each entry's `last_heard`
162    /// timestamp; entries older than `max_age` are evicted.
163    pub fn purge_expired(&mut self, now: Instant) {
164        let max_age = self.max_age;
165        self.stations
166            .retain(|_, e| now.duration_since(e.last_heard) < max_age);
167    }
168
169    /// Total number of stations tracked.
170    #[must_use]
171    pub fn len(&self) -> usize {
172        self.stations.len()
173    }
174
175    /// Returns `true` if the station list is empty.
176    #[must_use]
177    pub fn is_empty(&self) -> bool {
178        self.stations.is_empty()
179    }
180
181    /// Remove the oldest station entry to make room.
182    fn evict_oldest(&mut self) {
183        if let Some(oldest_key) = self
184            .stations
185            .iter()
186            .min_by_key(|(_, e)| e.last_heard)
187            .map(|(k, _)| k.clone())
188        {
189            let _removed = self.stations.remove(&oldest_key);
190        }
191    }
192}
193
194/// Haversine great-circle distance between two lat/lon points in kilometres.
195fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
196    let d_lat = (lat2 - lat1).to_radians();
197    let d_lon = (lon2 - lon1).to_radians();
198    let lat1_r = lat1.to_radians();
199    let lat2_r = lat2.to_radians();
200
201    let a = (lat1_r.cos() * lat2_r.cos())
202        .mul_add((d_lon / 2.0).sin().powi(2), (d_lat / 2.0).sin().powi(2));
203    let c = 2.0 * a.sqrt().asin();
204    EARTH_RADIUS_KM * c
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::message::AprsMessage;
211    use crate::packet::{AprsDataExtension, PositionAmbiguity};
212    use crate::status::AprsStatus;
213
214    type TestResult = Result<(), Box<dyn std::error::Error>>;
215
216    fn make_position(lat: f64, lon: f64) -> AprsData {
217        AprsData::Position(AprsPosition {
218            latitude: lat,
219            longitude: lon,
220            symbol_table: '/',
221            symbol_code: '>',
222            speed_knots: None,
223            course_degrees: None,
224            comment: String::new(),
225            weather: None,
226            extensions: AprsDataExtension::default(),
227            mice_message: None,
228            mice_altitude_m: None,
229            ambiguity: PositionAmbiguity::None,
230        })
231    }
232
233    fn make_status(text: &str) -> AprsData {
234        AprsData::Status(AprsStatus {
235            text: text.to_owned(),
236        })
237    }
238
239    fn make_weather() -> AprsData {
240        AprsData::Weather(AprsWeather {
241            wind_direction: Some(180),
242            wind_speed: Some(10),
243            wind_gust: None,
244            temperature: Some(72),
245            rain_1h: None,
246            rain_24h: None,
247            rain_since_midnight: None,
248            humidity: Some(55),
249            pressure: None,
250        })
251    }
252
253    #[test]
254    fn new_station_list_is_empty() {
255        let sl = StationList::new(100, Duration::from_secs(3600));
256        assert!(sl.is_empty());
257        assert_eq!(sl.len(), 0);
258    }
259
260    #[test]
261    fn update_creates_and_increments() -> TestResult {
262        let mut sl = StationList::new(100, Duration::from_secs(3600));
263        let pos = make_position(35.0, -97.0);
264        let t0 = Instant::now();
265        sl.update("N0CALL", &pos, &["WIDE1-1".to_owned()], t0);
266
267        assert_eq!(sl.len(), 1);
268        let entry = sl.get("N0CALL").ok_or("expected N0CALL entry")?;
269        assert_eq!(entry.callsign, "N0CALL");
270        assert_eq!(entry.packet_count, 1);
271        assert!(entry.position.is_some());
272        assert_eq!(entry.last_path, vec!["WIDE1-1".to_owned()]);
273
274        // Second update increments count.
275        sl.update("N0CALL", &pos, &[], t0);
276        let entry = sl.get("N0CALL").ok_or("expected N0CALL entry")?;
277        assert_eq!(entry.packet_count, 2);
278        Ok(())
279    }
280
281    #[test]
282    fn update_status_and_weather() -> TestResult {
283        let mut sl = StationList::new(100, Duration::from_secs(3600));
284        let t0 = Instant::now();
285
286        sl.update("WX1", &make_status("Sunny"), &[], t0);
287        let entry = sl.get("WX1").ok_or("expected WX1 entry")?;
288        assert_eq!(entry.last_status.as_deref(), Some("Sunny"));
289        assert!(entry.last_weather.is_none());
290
291        sl.update("WX1", &make_weather(), &[], t0);
292        let entry = sl.get("WX1").ok_or("expected WX1 entry")?;
293        assert!(entry.last_weather.is_some());
294        assert_eq!(entry.packet_count, 2);
295        Ok(())
296    }
297
298    #[test]
299    fn message_does_not_update_position_or_status() -> TestResult {
300        let mut sl = StationList::new(100, Duration::from_secs(3600));
301        let pos = make_position(35.0, -97.0);
302        let t0 = Instant::now();
303        sl.update("N0CALL", &pos, &[], t0);
304
305        let msg = AprsData::Message(AprsMessage {
306            addressee: "W1AW".to_owned(),
307            text: "Hello".to_owned(),
308            message_id: None,
309            reply_ack: None,
310        });
311        sl.update("N0CALL", &msg, &[], t0);
312
313        let entry = sl.get("N0CALL").ok_or("expected N0CALL entry")?;
314        // Position should still be the original.
315        assert!(entry.position.is_some());
316        assert_eq!(entry.packet_count, 2);
317        Ok(())
318    }
319
320    #[test]
321    fn get_nonexistent_returns_none() {
322        let sl = StationList::new(100, Duration::from_secs(3600));
323        assert!(sl.get("NOBODY").is_none());
324    }
325
326    #[test]
327    fn recent_returns_sorted_by_last_heard() -> TestResult {
328        let mut sl = StationList::new(100, Duration::from_secs(3600));
329        let pos = make_position(35.0, -97.0);
330        let t0 = Instant::now();
331
332        sl.update("FIRST", &pos, &[], t0);
333        sl.update("SECOND", &pos, &[], t0 + Duration::from_millis(1));
334        sl.update("THIRD", &pos, &[], t0 + Duration::from_millis(2));
335
336        let recent = sl.recent();
337        assert_eq!(recent.len(), 3);
338        // Most recent should be last updated.
339        let first = recent.first().ok_or("expected at least one entry")?;
340        assert_eq!(first.callsign, "THIRD");
341        Ok(())
342    }
343
344    #[test]
345    fn nearby_filters_by_distance() -> TestResult {
346        let mut sl = StationList::new(100, Duration::from_secs(3600));
347        let t0 = Instant::now();
348
349        // Two stations: one close, one far.
350        sl.update("CLOSE", &make_position(35.01, -97.01), &[], t0);
351        sl.update("FAR", &make_position(40.0, -80.0), &[], t0);
352        // One station with no position.
353        sl.update("NOPOS", &make_status("No GPS"), &[], t0);
354
355        let nearby = sl.nearby(35.0, -97.0, 10.0);
356        assert_eq!(nearby.len(), 1);
357        let first = nearby.first().ok_or("expected a nearby entry")?;
358        assert_eq!(first.callsign, "CLOSE");
359        Ok(())
360    }
361
362    #[test]
363    fn evict_oldest_when_over_capacity() {
364        let mut sl = StationList::new(2, Duration::from_secs(3600));
365        let pos = make_position(35.0, -97.0);
366        let t0 = Instant::now();
367
368        sl.update("FIRST", &pos, &[], t0);
369        sl.update("SECOND", &pos, &[], t0 + Duration::from_millis(1));
370        assert_eq!(sl.len(), 2);
371
372        // Adding a third should evict the oldest (FIRST).
373        sl.update("THIRD", &pos, &[], t0 + Duration::from_millis(2));
374        assert_eq!(sl.len(), 2);
375        assert!(sl.get("FIRST").is_none());
376        assert!(sl.get("SECOND").is_some());
377        assert!(sl.get("THIRD").is_some());
378    }
379
380    #[test]
381    fn haversine_zero_distance() {
382        let d = haversine_km(35.0, -97.0, 35.0, -97.0);
383        assert!(d.abs() < 0.001);
384    }
385
386    #[test]
387    fn haversine_known_distance() {
388        // New York to London: approximately 5,570 km.
389        let d = haversine_km(40.7128, -74.0060, 51.5074, -0.1278);
390        assert!((d - 5_570.0).abs() < 50.0);
391    }
392
393    #[test]
394    fn purge_expired_is_no_op_for_fresh_entries() {
395        let mut sl = StationList::new(100, Duration::from_secs(3600));
396        let t0 = Instant::now();
397        sl.update("N0CALL", &make_position(35.0, -97.0), &[], t0);
398        sl.purge_expired(t0);
399        assert_eq!(sl.len(), 1);
400    }
401
402    #[test]
403    fn purge_expired_removes_old_entries() {
404        // Use a reasonable max_age. We can now drive the clock directly
405        // via the injected `now` parameter instead of backdating
406        // `last_heard` after the fact.
407        let mut sl = StationList::new(100, Duration::from_secs(60));
408        let t0 = Instant::now();
409        sl.update("N0CALL", &make_position(35.0, -97.0), &[], t0);
410        assert_eq!(sl.len(), 1);
411
412        // Advance the clock past max_age.
413        let future = t0 + Duration::from_secs(120);
414        sl.purge_expired(future);
415        assert_eq!(sl.len(), 0, "expired entry should have been purged");
416    }
417}