stargazer/tier2/
protocol.rs

1//! XLX UDP JSON monitor protocol message types.
2//!
3//! XLX reflectors (xlxd) expose a UDP push-notification interface on port 10001
4//! for real-time activity monitoring. The protocol is simple:
5//!
6//! 1. Client sends `"hello"` as a UDP datagram to the reflector's IP on port
7//!    10001.
8//! 2. The server responds with **three** separate UDP datagrams, each containing
9//!    one JSON object:
10//!    - [`MonitorMessage::Reflector`]: reflector identity and available modules.
11//!    - [`MonitorMessage::Nodes`]: snapshot of all currently connected nodes.
12//!    - [`MonitorMessage::Stations`]: snapshot of recently heard stations.
13//! 3. After the initial dump, the server pushes updates as events occur:
14//!    - [`MonitorMessage::Nodes`]: whenever a node connects or disconnects.
15//!    - [`MonitorMessage::Stations`]: whenever a station is heard.
16//!    - [`MonitorMessage::OnAir`]: when a station starts transmitting.
17//!    - [`MonitorMessage::OffAir`]: when a station stops transmitting.
18//! 4. Client sends `"bye"` to disconnect cleanly.
19//!
20//! Each UDP datagram contains exactly one complete JSON object (no framing, no
21//! length prefix). The maximum node dump is 250 entries per datagram, and the
22//! server's update period is approximately 10 seconds.
23//!
24//! # Parsing strategy
25//!
26//! Because all messages arrive as untagged JSON objects, [`parse`] attempts to
27//! deserialize each shape in a specific order. The order matters because some
28//! shapes are subsets of others — for example, a `{"nodes":[...]}` object would
29//! match both the nodes shape and a hypothetical catch-all. The parse order is:
30//!
31//! 1. `OnAir` / `OffAir` — smallest, most distinctive keys.
32//! 2. `Reflector` — has the unique `"reflector"` + `"modules"` combination.
33//! 3. `Nodes` — has the `"nodes"` array key.
34//! 4. `Stations` — has the `"stations"` array key.
35//! 5. `Unknown` — fallback for unrecognized messages (logged, not fatal).
36
37use serde::Deserialize;
38
39/// Identity and module list for a reflector.
40///
41/// Sent once as the first message after connecting. The `reflector` field is
42/// the reflector's callsign (e.g. `"XLX039  "` with padding) and `modules`
43/// lists which module letters (A-Z) are available.
44///
45/// Example JSON:
46/// ```json
47/// {"reflector":"XLX039  ","modules":["A","B","C","D","E"]}
48/// ```
49#[derive(Debug, Clone, Deserialize)]
50pub(crate) struct ReflectorInfo {
51    /// Reflector callsign, possibly right-padded with spaces.
52    pub(crate) reflector: String,
53
54    /// Available module letters (e.g. `["A", "B", "C"]`).
55    pub(crate) modules: Vec<String>,
56}
57
58/// A single connected node entry from the `"nodes"` array.
59///
60/// Represents a gateway or hotspot currently linked to the reflector. The
61/// `callsign` includes the module suffix (e.g. `"W1AW  B"` — the trailing
62/// letter is the node's local module). The `linkedto` field indicates which
63/// reflector module the node is linked to.
64///
65/// The JSON payload also carries `module` (the node's local module letter —
66/// redundant with the suffix of `callsign`) and `time` (a server-locale
67/// human-readable timestamp). These are ignored by serde because they do
68/// not drive any business logic; the upstream xlxd code treats them as
69/// display-only, and the `connected_nodes` table stores its own normalized
70/// timestamps populated by the monitor.
71///
72/// Example JSON element:
73/// ```json
74/// {"callsign":"W1AW  B","module":"B","linkedto":"A","time":"Tuesday Nov 17..."}
75/// ```
76#[derive(Debug, Clone, Deserialize)]
77pub(crate) struct NodeInfo {
78    /// Node callsign with module suffix (e.g. `"W1AW  B"`).
79    pub(crate) callsign: String,
80
81    /// Reflector module letter the node is linked to (e.g. `"A"`).
82    pub(crate) linkedto: String,
83}
84
85/// A single heard station entry from the `"stations"` array.
86///
87/// Represents an operator who was recently heard transmitting through a node
88/// linked to the reflector. The JSON payload also carries `node` (the relaying
89/// gateway callsign) and `time` (a server-locale human-readable timestamp).
90/// These are ignored by serde because activity is tracked via the reflector +
91/// module + callsign triple, and timestamps are normalized to `Utc::now()`
92/// when the observation is inserted into `activity_log`.
93///
94/// Example JSON element:
95/// ```json
96/// {"callsign":"W1AW    ","node":"W1AW  B ","module":"B","time":"Tuesday Nov 17..."}
97/// ```
98#[derive(Debug, Clone, Deserialize)]
99pub(crate) struct StationInfo {
100    /// Operator callsign, possibly right-padded with spaces.
101    pub(crate) callsign: String,
102
103    /// Reflector module letter where the station was heard.
104    pub(crate) module: String,
105}
106
107/// A parsed XLX monitor protocol message.
108///
109/// Each variant corresponds to one of the JSON object shapes the xlxd monitor
110/// protocol can produce. See the [module-level documentation](self) for the
111/// full protocol description and parse ordering rationale.
112#[derive(Debug, Clone)]
113pub(crate) enum MonitorMessage {
114    /// Reflector identity and available modules.
115    ///
116    /// Sent once immediately after the initial `"hello"` handshake.
117    Reflector(ReflectorInfo),
118
119    /// Snapshot or update of connected nodes.
120    ///
121    /// Sent once during the initial handshake (full snapshot), then again
122    /// whenever a node connects or disconnects (incremental update with the
123    /// full current list).
124    Nodes(Vec<NodeInfo>),
125
126    /// Snapshot or update of recently heard stations.
127    ///
128    /// Sent once during the initial handshake, then again whenever a station
129    /// is heard.
130    Stations(Vec<StationInfo>),
131
132    /// A station has started transmitting (keyed up).
133    ///
134    /// The contained string is the operator callsign (possibly padded).
135    OnAir(String),
136
137    /// A station has stopped transmitting (unkeyed).
138    ///
139    /// The contained string is the operator callsign (possibly padded).
140    OffAir(String),
141
142    /// An unrecognized JSON message.
143    ///
144    /// Logged for diagnostic purposes but not processed further. This
145    /// accommodates future protocol extensions without breaking the client.
146    Unknown(String),
147}
148
149// ---------------------------------------------------------------------------
150// Internal serde helper structs for untagged deserialization.
151//
152// Because the XLX monitor protocol uses untagged JSON objects (no
153// discriminator field), we attempt to deserialize each shape independently.
154// These helper structs exist solely for serde; the public API uses
155// `MonitorMessage`.
156// ---------------------------------------------------------------------------
157
158/// Helper for `{"onair":"CALLSIGN"}`.
159#[derive(Deserialize)]
160struct OnAirMsg {
161    onair: String,
162}
163
164/// Helper for `{"offair":"CALLSIGN"}`.
165#[derive(Deserialize)]
166struct OffAirMsg {
167    offair: String,
168}
169
170/// Helper for `{"reflector":"...","modules":[...]}`.
171#[derive(Deserialize)]
172struct ReflectorMsg {
173    reflector: String,
174    modules: Vec<String>,
175}
176
177/// Helper for `{"nodes":[...]}`.
178#[derive(Deserialize)]
179struct NodesMsg {
180    nodes: Vec<NodeInfo>,
181}
182
183/// Helper for `{"stations":[...]}`.
184#[derive(Deserialize)]
185struct StationsMsg {
186    stations: Vec<StationInfo>,
187}
188
189/// Attempts to parse a raw UDP datagram payload into a [`MonitorMessage`].
190///
191/// Returns `Some(message)` on successful parse, or `None` if the data is not
192/// valid UTF-8 or not valid JSON (e.g., a stray/corrupt datagram).
193///
194/// # Parse order
195///
196/// The shapes are tried in a deliberate order to avoid ambiguous matches:
197///
198/// 1. **`OnAir`** — Tiny single-key object `{"onair":"..."}`. Tried first
199///    because it is the most common real-time event during active transmissions
200///    and is unambiguous (unique key name).
201///
202/// 2. **`OffAir`** — Tiny single-key object `{"offair":"..."}`. Same rationale
203///    as `OnAir`.
204///
205/// 3. **`Reflector`** — Two-key object `{"reflector":"...","modules":[...]}`.
206///    The key combination is unique, and this is only sent once per session, so
207///    false positives are not a concern.
208///
209/// 4. **`Nodes`** — Single-key object `{"nodes":[...]}` containing an array of
210///    node entries. Tried before `Stations` because node updates are more
211///    frequent than station updates in practice.
212///
213/// 5. **`Stations`** — Single-key object `{"stations":[...]}` containing an
214///    array of station entries.
215///
216/// 6. **`Unknown`** — If none of the above match, the raw JSON text is wrapped
217///    in `Unknown` for diagnostic logging. This ensures forward compatibility
218///    with potential protocol extensions.
219pub(crate) fn parse(data: &[u8]) -> Option<MonitorMessage> {
220    // The XLX monitor protocol uses UTF-8 JSON. Reject non-UTF-8 immediately.
221    let text = std::str::from_utf8(data).ok()?;
222
223    // Try OnAir first — smallest, most frequent real-time event.
224    if let Ok(msg) = serde_json::from_str::<OnAirMsg>(text) {
225        return Some(MonitorMessage::OnAir(msg.onair));
226    }
227
228    // Try OffAir — same rationale as OnAir.
229    if let Ok(msg) = serde_json::from_str::<OffAirMsg>(text) {
230        return Some(MonitorMessage::OffAir(msg.offair));
231    }
232
233    // Try Reflector — unique two-key shape, sent once per session.
234    if let Ok(msg) = serde_json::from_str::<ReflectorMsg>(text) {
235        return Some(MonitorMessage::Reflector(ReflectorInfo {
236            reflector: msg.reflector,
237            modules: msg.modules,
238        }));
239    }
240
241    // Try Nodes — array of connected node entries.
242    if let Ok(msg) = serde_json::from_str::<NodesMsg>(text) {
243        return Some(MonitorMessage::Nodes(msg.nodes));
244    }
245
246    // Try Stations — array of heard station entries.
247    if let Ok(msg) = serde_json::from_str::<StationsMsg>(text) {
248        return Some(MonitorMessage::Stations(msg.stations));
249    }
250
251    // Unrecognized JSON — log the raw text for diagnostics. This preserves
252    // forward compatibility: new message types from future xlxd versions
253    // won't crash the client.
254    Some(MonitorMessage::Unknown(text.to_owned()))
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn parse_onair_message() {
263        let data = br#"{"onair":"W1AW    "}"#;
264        let msg = parse(data);
265        assert!(
266            matches!(&msg, Some(MonitorMessage::OnAir(cs)) if cs == "W1AW    "),
267            "expected OnAir, got {msg:?}"
268        );
269    }
270
271    #[test]
272    fn parse_offair_message() {
273        let data = br#"{"offair":"W1AW    "}"#;
274        let msg = parse(data);
275        assert!(
276            matches!(&msg, Some(MonitorMessage::OffAir(cs)) if cs == "W1AW    "),
277            "expected OffAir, got {msg:?}"
278        );
279    }
280
281    #[test]
282    fn parse_reflector_message() {
283        let data = br#"{"reflector":"XLX039  ","modules":["A","B","C"]}"#;
284        let msg = parse(data);
285        assert!(
286            matches!(&msg, Some(MonitorMessage::Reflector(info)) if info.reflector == "XLX039  " && info.modules.len() == 3),
287            "expected Reflector, got {msg:?}"
288        );
289    }
290
291    #[test]
292    fn parse_nodes_message() {
293        let data = br#"{"nodes":[{"callsign":"W1AW  B","module":"B","linkedto":"A","time":"Tue Nov 17"}]}"#;
294        let msg = parse(data);
295        assert!(
296            matches!(&msg, Some(MonitorMessage::Nodes(nodes)) if nodes.len() == 1),
297            "expected Nodes, got {msg:?}"
298        );
299    }
300
301    #[test]
302    fn parse_stations_message() {
303        let data = br#"{"stations":[{"callsign":"W1AW    ","node":"W1AW  B ","module":"B","time":"Tue Nov 17"}]}"#;
304        let msg = parse(data);
305        assert!(
306            matches!(&msg, Some(MonitorMessage::Stations(stations)) if stations.len() == 1),
307            "expected Stations, got {msg:?}"
308        );
309    }
310
311    #[test]
312    fn parse_empty_nodes_array() {
313        let data = br#"{"nodes":[]}"#;
314        let msg = parse(data);
315        assert!(
316            matches!(&msg, Some(MonitorMessage::Nodes(nodes)) if nodes.is_empty()),
317            "expected empty Nodes, got {msg:?}"
318        );
319    }
320
321    #[test]
322    fn parse_unknown_message() {
323        let data = br#"{"something":"unexpected"}"#;
324        let msg = parse(data);
325        assert!(
326            matches!(&msg, Some(MonitorMessage::Unknown(_))),
327            "expected Unknown, got {msg:?}"
328        );
329    }
330
331    #[test]
332    fn parse_invalid_utf8_returns_none() {
333        let data: &[u8] = &[0xFF, 0xFE, 0xFD];
334        let msg = parse(data);
335        assert!(
336            msg.is_none(),
337            "expected None for invalid UTF-8, got {msg:?}"
338        );
339    }
340
341    #[test]
342    fn parse_non_json_utf8_returns_unknown() {
343        // Valid UTF-8 but not valid JSON. Falls through all serde attempts
344        // and is wrapped in Unknown for forward-compatibility logging.
345        let data = b"not json at all";
346        let msg = parse(data);
347        assert!(
348            matches!(&msg, Some(MonitorMessage::Unknown(s)) if s == "not json at all"),
349            "expected Unknown, got {msg:?}"
350        );
351    }
352}