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}