aprs_is/
filter.rs

1//! APRS-IS server-side filter expressions.
2
3/// Structured APRS-IS filter expression.
4///
5/// Per <http://www.aprs-is.net/javAPRSFilter.aspx>, APRS-IS servers
6/// accept a small query language for selecting which packets to deliver
7/// to a client connection. Each filter is one or more tokens separated
8/// by spaces. This enum covers the commonly-used forms; use
9/// [`AprsIsFilter::raw`] to drop in any literal filter string for
10/// advanced cases.
11#[derive(Debug, Clone, PartialEq)]
12pub enum AprsIsFilter {
13    /// Range filter `r/lat/lon/distance_km` — packets from stations
14    /// within the given radius.
15    Range {
16        /// Centre latitude in degrees (positive = North).
17        lat: f64,
18        /// Centre longitude in degrees (positive = East).
19        lon: f64,
20        /// Radius in kilometres.
21        distance_km: f64,
22    },
23    /// Area / box filter `a/lat1/lon1/lat2/lon2` — packets within a
24    /// lat/lon bounding box (NW and SE corners).
25    Area {
26        /// Northwest latitude.
27        lat1: f64,
28        /// Northwest longitude.
29        lon1: f64,
30        /// Southeast latitude.
31        lat2: f64,
32        /// Southeast longitude.
33        lon2: f64,
34    },
35    /// Prefix filter `p/aa/bb/cc` — packets whose source callsign
36    /// begins with any of the given prefixes.
37    Prefix(Vec<String>),
38    /// Budlist filter `b/call1/call2` — packets from exactly these
39    /// stations.
40    Budlist(Vec<String>),
41    /// Object filter `o/obj1/obj2` — object reports with these names.
42    Object(Vec<String>),
43    /// Type filter `t/poimntqsu` — characters select which frame types
44    /// are wanted (p=position, o=object, i=item, m=message, n=nws,
45    /// t=telemetry, q=query, s=status, u=user-defined).
46    Type(String),
47    /// Symbol filter `s/sym1sym2/...` — symbols to include.
48    Symbol(String),
49    /// "Friend" / range-around-station filter `f/call/distance_km`.
50    Friend {
51        /// Station to centre on.
52        callsign: String,
53        /// Distance in km.
54        distance_km: f64,
55    },
56    /// Group message filter `g/name` — bulletins addressed to this
57    /// group.
58    Group(String),
59    /// Raw literal filter string for advanced / uncommon cases.
60    Raw(String),
61}
62
63impl AprsIsFilter {
64    /// Build a raw literal filter expression.
65    #[must_use]
66    pub fn raw(s: impl Into<String>) -> Self {
67        Self::Raw(s.into())
68    }
69
70    /// Format this filter as the exact wire-format string APRS-IS
71    /// servers expect after the `filter ` keyword in the login line.
72    #[must_use]
73    pub fn as_wire(&self) -> String {
74        match self {
75            Self::Range {
76                lat,
77                lon,
78                distance_km,
79            } => format!("r/{lat}/{lon}/{distance_km}"),
80            Self::Area {
81                lat1,
82                lon1,
83                lat2,
84                lon2,
85            } => format!("a/{lat1}/{lon1}/{lat2}/{lon2}"),
86            Self::Prefix(parts) => format!("p/{}", parts.join("/")),
87            Self::Budlist(parts) => format!("b/{}", parts.join("/")),
88            Self::Object(parts) => format!("o/{}", parts.join("/")),
89            Self::Type(chars) => format!("t/{chars}"),
90            Self::Symbol(chars) => format!("s/{chars}"),
91            Self::Friend {
92                callsign,
93                distance_km,
94            } => format!("f/{callsign}/{distance_km}"),
95            Self::Group(name) => format!("g/{name}"),
96            Self::Raw(s) => s.clone(),
97        }
98    }
99
100    /// Combine multiple filter clauses into a single filter string by
101    /// joining with spaces — APRS-IS allows an OR of any number of
102    /// clauses in a single `filter` directive.
103    #[must_use]
104    pub fn join(filters: &[Self]) -> String {
105        filters
106            .iter()
107            .map(Self::as_wire)
108            .collect::<Vec<_>>()
109            .join(" ")
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn aprs_is_filter_range_wire_format() {
119        let f = AprsIsFilter::Range {
120            lat: 35.25,
121            lon: -97.75,
122            distance_km: 100.0,
123        };
124        assert_eq!(f.as_wire(), "r/35.25/-97.75/100");
125    }
126
127    #[test]
128    fn aprs_is_filter_type_and_prefix() {
129        let f = AprsIsFilter::Type("po".to_owned());
130        assert_eq!(f.as_wire(), "t/po");
131        let f = AprsIsFilter::Prefix(vec!["KK".to_owned(), "W1".to_owned()]);
132        assert_eq!(f.as_wire(), "p/KK/W1");
133    }
134
135    #[test]
136    fn aprs_is_filter_join_multiple() {
137        let filters = vec![
138            AprsIsFilter::Range {
139                lat: 35.0,
140                lon: -97.0,
141                distance_km: 50.0,
142            },
143            AprsIsFilter::Type("p".to_owned()),
144        ];
145        let joined = AprsIsFilter::join(&filters);
146        assert!(joined.contains("r/35"), "missing range clause: {joined:?}");
147        assert!(joined.contains("t/p"), "missing type clause: {joined:?}");
148        assert!(joined.contains(' '), "missing separator: {joined:?}");
149    }
150
151    #[test]
152    fn aprs_is_filter_raw_passthrough() {
153        let f = AprsIsFilter::raw("m/50");
154        assert_eq!(f.as_wire(), "m/50");
155    }
156}