1use std::collections::HashMap;
16use std::time::{Duration, Instant};
17
18use crate::packet::AprsData;
19use crate::position::AprsPosition;
20use crate::weather::AprsWeather;
21
22const EARTH_RADIUS_KM: f64 = 6_371.0;
24
25#[derive(Debug)]
27pub struct StationList {
28 stations: HashMap<String, StationEntry>,
30 max_entries: usize,
32 max_age: Duration,
34}
35
36#[derive(Debug, Clone)]
38pub struct StationEntry {
39 pub callsign: String,
41 pub last_heard: Instant,
43 pub position: Option<AprsPosition>,
45 pub last_status: Option<String>,
47 pub last_weather: Option<AprsWeather>,
49 pub packet_count: u32,
51 pub last_path: Vec<String>,
53}
54
55impl StationList {
56 #[must_use]
58 #[allow(clippy::missing_const_for_fn)] 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 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 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 }
118 AprsData::Weather(wx) => {
119 entry.last_weather = Some(wx.clone());
120 }
121 }
122
123 if self.stations.len() > self.max_entries {
125 self.evict_oldest();
126 }
127 }
128
129 #[must_use]
131 pub fn get(&self, callsign: &str) -> Option<&StationEntry> {
132 self.stations.get(callsign)
133 }
134
135 #[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 #[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 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 #[must_use]
171 pub fn len(&self) -> usize {
172 self.stations.len()
173 }
174
175 #[must_use]
177 pub fn is_empty(&self) -> bool {
178 self.stations.is_empty()
179 }
180
181 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
194fn 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 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 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 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 sl.update("CLOSE", &make_position(35.01, -97.01), &[], t0);
351 sl.update("FAR", &make_position(40.0, -80.0), &[], t0);
352 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 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 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 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 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}