aprs/
build.rs

1//! Builders for outgoing APRS info fields and wire frames.
2//!
3//! Each public entry point has two flavours: the top-level builder
4//! returns a KISS-framed byte vector ready for transport write, and the
5//! `_packet` variant returns the unencoded [`Ax25Packet`] so callers can
6//! inspect, log, or route it before wrapping it in KISS framing.
7
8use ax25_codec::{Ax25Address, Ax25Packet, build_ax25};
9use kiss_tnc::{KissFrame, encode_kiss_frame};
10
11use crate::error::AprsError;
12use crate::message::MAX_APRS_MESSAGE_TEXT_LEN;
13use crate::mic_e::{MiceMessage, mice_message_bits};
14use crate::packet::AprsTimestamp;
15use crate::weather::AprsWeather;
16
17// ---------------------------------------------------------------------------
18// Private constants and helpers
19// ---------------------------------------------------------------------------
20
21/// APRS tocall for the Kenwood TH-D75 (per APRS tocall registry).
22const APRS_TOCALL: &str = "APK005";
23
24/// Build a minimal APRS UI frame with the given source, destination, path,
25/// and info field. Control = 0x03, PID = 0xF0.
26const fn ax25_ui_frame(
27    source: Ax25Address,
28    destination: Ax25Address,
29    path: Vec<Ax25Address>,
30    info: Vec<u8>,
31) -> Ax25Packet {
32    Ax25Packet {
33        source,
34        destination,
35        digipeaters: path,
36        control: 0x03,
37        protocol: 0xF0,
38        info,
39    }
40}
41
42/// Encode an [`Ax25Packet`] as a KISS-framed data frame ready for the
43/// wire.
44fn ax25_to_kiss_wire(packet: &Ax25Packet) -> Vec<u8> {
45    let ax25_bytes = build_ax25(packet);
46    encode_kiss_frame(&KissFrame::data(ax25_bytes))
47}
48
49/// Format latitude as APRS uncompressed `DDMM.HHN` (8 bytes).
50///
51/// Clamps out-of-range or non-finite input to `±90.0` so the output is
52/// always a well-formed 8-byte APRS latitude field instead of garbage
53/// like `"950000.00N"`.
54fn format_aprs_latitude(lat: f64) -> String {
55    let lat = if lat.is_finite() {
56        lat.clamp(-90.0, 90.0)
57    } else {
58        0.0
59    };
60    let hemisphere = if lat >= 0.0 { 'N' } else { 'S' };
61    let lat_abs = lat.abs();
62    #[expect(
63        clippy::cast_possible_truncation,
64        clippy::cast_sign_loss,
65        reason = "lat_abs is clamped to 0..=90 so the cast to u32 is safe"
66    )]
67    let degrees = lat_abs as u32;
68    let minutes = (lat_abs - f64::from(degrees)) * 60.0;
69    format!("{degrees:02}{minutes:05.2}{hemisphere}")
70}
71
72/// Format longitude as APRS uncompressed `DDDMM.HHE` (9 bytes).
73///
74/// Clamps out-of-range or non-finite input to `±180.0`.
75fn format_aprs_longitude(lon: f64) -> String {
76    let lon = if lon.is_finite() {
77        lon.clamp(-180.0, 180.0)
78    } else {
79        0.0
80    };
81    let hemisphere = if lon >= 0.0 { 'E' } else { 'W' };
82    let lon_abs = lon.abs();
83    #[expect(
84        clippy::cast_possible_truncation,
85        clippy::cast_sign_loss,
86        reason = "lon_abs is clamped to 0..=180 so the cast to u32 is safe"
87    )]
88    let degrees = lon_abs as u32;
89    let minutes = (lon_abs - f64::from(degrees)) * 60.0;
90    format!("{degrees:03}{minutes:05.2}{hemisphere}")
91}
92
93/// Encode a `u32` value as 4 bytes of base-91.
94///
95/// Base-91 encoding uses characters 33 (`!`) through 123 (`{`), giving
96/// 91 possible values per byte. Four bytes can represent values up to
97/// 91^4 - 1 = 68,574,960.
98fn encode_base91_4(mut value: u32) -> [u8; 4] {
99    let mut out = [0u8; 4];
100    for slot in out.iter_mut().rev() {
101        // value % 91 is in 0..91 so the truncation to u8 is safe.
102        let digit = (value % 91) as u8;
103        *slot = digit + 33;
104        value /= 91;
105    }
106    out
107}
108
109// ---------------------------------------------------------------------------
110// APRS position builder (uncompressed)
111// ---------------------------------------------------------------------------
112
113/// Build a KISS-encoded APRS uncompressed position report.
114///
115/// Composes an AX.25 UI frame with:
116/// - Destination: `APK005-0` (Kenwood TH-D75 tocall)
117/// - Digipeater path: WIDE1-1, WIDE2-1
118/// - Info field: `!DDMM.HHN/DDDMM.HHEscomment`
119///
120/// Returns wire-ready bytes (FEND-delimited KISS frame) suitable for
121/// direct transport write.
122///
123/// # Parameters
124///
125/// - `source`: The sender's callsign and SSID.
126/// - `latitude`: Decimal degrees, positive = North, negative = South.
127/// - `longitude`: Decimal degrees, positive = East, negative = West.
128/// - `symbol_table`: APRS symbol table character (`/` for primary, `\\` for alternate).
129/// - `symbol_code`: APRS symbol code character (e.g., `>` for car, `-` for house).
130/// - `comment`: Free-form comment text appended after the position.
131/// - `path`: Digipeater path. Supply an empty slice for direct
132///   transmission with no digipeating.
133#[must_use]
134pub fn build_aprs_position_report(
135    source: &Ax25Address,
136    latitude: f64,
137    longitude: f64,
138    symbol_table: char,
139    symbol_code: char,
140    comment: &str,
141    path: &[Ax25Address],
142) -> Vec<u8> {
143    ax25_to_kiss_wire(&build_aprs_position_report_packet(
144        source,
145        latitude,
146        longitude,
147        symbol_table,
148        symbol_code,
149        comment,
150        path,
151    ))
152}
153
154/// Like [`build_aprs_position_report`] but returns the unencoded
155/// [`Ax25Packet`] so callers can inspect, log, or route it before
156/// wrapping it in KISS framing.
157#[must_use]
158pub fn build_aprs_position_report_packet(
159    source: &Ax25Address,
160    latitude: f64,
161    longitude: f64,
162    symbol_table: char,
163    symbol_code: char,
164    comment: &str,
165    path: &[Ax25Address],
166) -> Ax25Packet {
167    let lat_str = format_aprs_latitude(latitude);
168    let lon_str = format_aprs_longitude(longitude);
169    let info = format!("!{lat_str}{symbol_table}{lon_str}{symbol_code}{comment}");
170    ax25_ui_frame(
171        source.clone(),
172        Ax25Address::new(APRS_TOCALL, 0),
173        path.to_vec(),
174        info.into_bytes(),
175    )
176}
177
178// ---------------------------------------------------------------------------
179// APRS message builders
180// ---------------------------------------------------------------------------
181
182/// Build a KISS-encoded APRS message packet.
183///
184/// Composes an AX.25 UI frame with the APRS message format:
185/// `:ADDRESSEE:text{ID`
186///
187/// The addressee is padded to exactly 9 characters per the APRS spec.
188/// Message text that exceeds [`MAX_APRS_MESSAGE_TEXT_LEN`] (67 bytes) is
189/// **truncated** — use [`build_aprs_message_checked`] if you want a
190/// hard error on overlong input.
191///
192/// Returns wire-ready bytes (FEND-delimited KISS frame).
193///
194/// # Parameters
195///
196/// - `source`: The sender's callsign and SSID.
197/// - `addressee`: Destination station callsign (up to 9 chars).
198/// - `text`: Message text content.
199/// - `message_id`: Optional message sequence number for ack/rej tracking.
200/// - `path`: Digipeater path.
201#[must_use]
202pub fn build_aprs_message(
203    source: &Ax25Address,
204    addressee: &str,
205    text: &str,
206    message_id: Option<&str>,
207    path: &[Ax25Address],
208) -> Vec<u8> {
209    ax25_to_kiss_wire(&build_aprs_message_packet(
210        source, addressee, text, message_id, path,
211    ))
212}
213
214/// Like [`build_aprs_message`] but returns the unencoded [`Ax25Packet`].
215#[must_use]
216pub fn build_aprs_message_packet(
217    source: &Ax25Address,
218    addressee: &str,
219    text: &str,
220    message_id: Option<&str>,
221    path: &[Ax25Address],
222) -> Ax25Packet {
223    // Pad addressee to exactly 9 characters.
224    let padded_addressee = format!("{addressee:<9}");
225    let padded_addressee = padded_addressee.get(..9).unwrap_or(&padded_addressee);
226
227    // Truncate text to the spec limit on a UTF-8 char boundary.
228    let text = if text.len() > MAX_APRS_MESSAGE_TEXT_LEN {
229        let mut end = MAX_APRS_MESSAGE_TEXT_LEN;
230        while end > 0 && !text.is_char_boundary(end) {
231            end -= 1;
232        }
233        text.get(..end).unwrap_or(text)
234    } else {
235        text
236    };
237
238    let info = message_id.map_or_else(
239        || format!(":{padded_addressee}:{text}"),
240        |id| format!(":{padded_addressee}:{text}{{{id}"),
241    );
242
243    ax25_ui_frame(
244        source.clone(),
245        Ax25Address::new(APRS_TOCALL, 0),
246        path.to_vec(),
247        info.into_bytes(),
248    )
249}
250
251/// Like [`build_aprs_message`] but returns an error when the text
252/// exceeds the APRS 1.0.1 67-byte limit instead of silently truncating.
253///
254/// # Errors
255///
256/// Returns [`AprsError::MessageTooLong`] if `text.len() > 67`.
257pub fn build_aprs_message_checked(
258    source: &Ax25Address,
259    addressee: &str,
260    text: &str,
261    message_id: Option<&str>,
262    path: &[Ax25Address],
263) -> Result<Vec<u8>, AprsError> {
264    if text.len() > MAX_APRS_MESSAGE_TEXT_LEN {
265        return Err(AprsError::MessageTooLong(text.len()));
266    }
267    Ok(build_aprs_message(
268        source, addressee, text, message_id, path,
269    ))
270}
271
272// ---------------------------------------------------------------------------
273// APRS object builders
274// ---------------------------------------------------------------------------
275
276/// Build a KISS-encoded APRS object report.
277///
278/// Composes an AX.25 UI frame with the APRS object format:
279/// `;name_____*DDHHMMzDDMM.HHN/DDDMM.HHEscomment`
280///
281/// The object name is padded to exactly 9 characters per the APRS spec.
282/// The timestamp uses the current UTC time in DHM zulu format.
283///
284/// Returns wire-ready bytes (FEND-delimited KISS frame).
285///
286/// # Parameters
287///
288/// - `source`: The sender's callsign and SSID.
289/// - `name`: Object name (up to 9 characters).
290/// - `live`: `true` for a live object (`*`), `false` for killed (`_`).
291/// - `latitude`: Decimal degrees, positive = North.
292/// - `longitude`: Decimal degrees, positive = East.
293/// - `symbol_table`: APRS symbol table character.
294/// - `symbol_code`: APRS symbol code character.
295/// - `comment`: Free-form comment text.
296/// - `path`: Digipeater path.
297#[must_use]
298#[expect(
299    clippy::too_many_arguments,
300    reason = "APRS object wire fields are fundamentally positional"
301)]
302pub fn build_aprs_object(
303    source: &Ax25Address,
304    name: &str,
305    live: bool,
306    latitude: f64,
307    longitude: f64,
308    symbol_table: char,
309    symbol_code: char,
310    comment: &str,
311    path: &[Ax25Address],
312) -> Vec<u8> {
313    // Use a placeholder DHM zulu timestamp `000000z`. Callers needing a
314    // real timestamp should use [`build_aprs_object_with_timestamp`].
315    build_aprs_object_with_timestamp(
316        source,
317        name,
318        live,
319        AprsTimestamp::DhmZulu {
320            day: 0,
321            hour: 0,
322            minute: 0,
323        },
324        latitude,
325        longitude,
326        symbol_table,
327        symbol_code,
328        comment,
329        path,
330    )
331}
332
333/// Build a KISS-encoded APRS object report with a caller-supplied
334/// timestamp.
335///
336/// Identical to [`build_aprs_object`] but uses the provided
337/// [`AprsTimestamp`] instead of the `000000z` placeholder.
338#[must_use]
339#[expect(
340    clippy::too_many_arguments,
341    reason = "APRS object wire fields are fundamentally positional"
342)]
343pub fn build_aprs_object_with_timestamp(
344    source: &Ax25Address,
345    name: &str,
346    live: bool,
347    timestamp: AprsTimestamp,
348    latitude: f64,
349    longitude: f64,
350    symbol_table: char,
351    symbol_code: char,
352    comment: &str,
353    path: &[Ax25Address],
354) -> Vec<u8> {
355    ax25_to_kiss_wire(&build_aprs_object_with_timestamp_packet(
356        source,
357        name,
358        live,
359        timestamp,
360        latitude,
361        longitude,
362        symbol_table,
363        symbol_code,
364        comment,
365        path,
366    ))
367}
368
369/// Like [`build_aprs_object_with_timestamp`] but returns the unencoded
370/// [`Ax25Packet`] for callers that want to inspect or route it.
371#[must_use]
372#[expect(
373    clippy::too_many_arguments,
374    reason = "APRS object wire fields are fundamentally positional"
375)]
376pub fn build_aprs_object_with_timestamp_packet(
377    source: &Ax25Address,
378    name: &str,
379    live: bool,
380    timestamp: AprsTimestamp,
381    latitude: f64,
382    longitude: f64,
383    symbol_table: char,
384    symbol_code: char,
385    comment: &str,
386    path: &[Ax25Address],
387) -> Ax25Packet {
388    let padded_name = format!("{name:<9}");
389    let padded_name = padded_name.get(..9).unwrap_or(&padded_name);
390    let live_char = if live { '*' } else { '_' };
391    let lat_str = format_aprs_latitude(latitude);
392    let lon_str = format_aprs_longitude(longitude);
393    let ts = timestamp.to_wire_string();
394
395    let info = format!(
396        ";{padded_name}{live_char}{ts}{lat_str}{symbol_table}{lon_str}{symbol_code}{comment}"
397    );
398
399    ax25_ui_frame(
400        source.clone(),
401        Ax25Address::new(APRS_TOCALL, 0),
402        path.to_vec(),
403        info.into_bytes(),
404    )
405}
406
407// ---------------------------------------------------------------------------
408// APRS item builders
409// ---------------------------------------------------------------------------
410
411/// Build a KISS-encoded APRS item report.
412///
413/// Composes an AX.25 UI frame with the APRS item format:
414/// `)name!DDMM.HHN/DDDMM.HHEscomment` (live) or
415/// `)name_DDMM.HHN/DDDMM.HHEscomment` (killed).
416///
417/// The item name must be 3-9 characters per APRS101 Chapter 11.
418///
419/// Returns wire-ready bytes (FEND-delimited KISS frame).
420///
421/// # Parameters
422///
423/// - `source`: The sender's callsign and SSID.
424/// - `name`: Item name (3-9 characters).
425/// - `live`: `true` for a live item (`!`), `false` for killed (`_`).
426/// - `lat`: Decimal degrees, positive = North.
427/// - `lon`: Decimal degrees, positive = East.
428/// - `symbol_table`: APRS symbol table character.
429/// - `symbol_code`: APRS symbol code character.
430/// - `comment`: Free-form comment text.
431/// - `path`: Digipeater path.
432#[must_use]
433#[expect(
434    clippy::too_many_arguments,
435    reason = "APRS item wire fields are fundamentally positional"
436)]
437pub fn build_aprs_item(
438    source: &Ax25Address,
439    name: &str,
440    live: bool,
441    lat: f64,
442    lon: f64,
443    symbol_table: char,
444    symbol_code: char,
445    comment: &str,
446    path: &[Ax25Address],
447) -> Vec<u8> {
448    ax25_to_kiss_wire(&build_aprs_item_packet(
449        source,
450        name,
451        live,
452        lat,
453        lon,
454        symbol_table,
455        symbol_code,
456        comment,
457        path,
458    ))
459}
460
461/// Like [`build_aprs_item`] but returns the unencoded [`Ax25Packet`].
462#[must_use]
463#[expect(
464    clippy::too_many_arguments,
465    reason = "APRS item wire fields are fundamentally positional"
466)]
467pub fn build_aprs_item_packet(
468    source: &Ax25Address,
469    name: &str,
470    live: bool,
471    lat: f64,
472    lon: f64,
473    symbol_table: char,
474    symbol_code: char,
475    comment: &str,
476    path: &[Ax25Address],
477) -> Ax25Packet {
478    let live_char = if live { '!' } else { '_' };
479    let lat_str = format_aprs_latitude(lat);
480    let lon_str = format_aprs_longitude(lon);
481    let info = format!("){name}{live_char}{lat_str}{symbol_table}{lon_str}{symbol_code}{comment}");
482    ax25_ui_frame(
483        source.clone(),
484        Ax25Address::new(APRS_TOCALL, 0),
485        path.to_vec(),
486        info.into_bytes(),
487    )
488}
489
490// ---------------------------------------------------------------------------
491// APRS weather builders
492// ---------------------------------------------------------------------------
493
494/// Build a KISS-encoded positionless APRS weather report.
495///
496/// Composes an AX.25 UI frame with the APRS positionless weather format:
497/// `_MMDDHHMMcSSSsSSS gSSS tTTT rRRR pRRR PRRR hHH bBBBBB`
498///
499/// Uses a placeholder timestamp (`00000000`). Callers needing a real
500/// timestamp should build the info field manually.
501///
502/// Returns wire-ready bytes (FEND-delimited KISS frame).
503///
504/// # Parameters
505///
506/// - `source`: The sender's callsign and SSID.
507/// - `weather`: Weather data to encode. Missing fields are omitted.
508/// - `path`: Digipeater path.
509#[must_use]
510pub fn build_aprs_weather(
511    source: &Ax25Address,
512    weather: &AprsWeather,
513    path: &[Ax25Address],
514) -> Vec<u8> {
515    ax25_to_kiss_wire(&build_aprs_weather_packet(source, weather, path))
516}
517
518/// Build a combined APRS position + weather report as a single KISS
519/// frame, per APRS 1.0.1 §12.1.
520///
521/// Uses the uncompressed position format with symbol code `_` (weather
522/// station), followed by the `DDD/SSS` CSE/SPD wind direction/speed
523/// extension, then the remaining weather fields. This is the "complete
524/// weather report" wire form used by most fixed weather stations.
525#[must_use]
526pub fn build_aprs_position_weather(
527    source: &Ax25Address,
528    latitude: f64,
529    longitude: f64,
530    symbol_table: char,
531    weather: &AprsWeather,
532    path: &[Ax25Address],
533) -> Vec<u8> {
534    ax25_to_kiss_wire(&build_aprs_position_weather_packet(
535        source,
536        latitude,
537        longitude,
538        symbol_table,
539        weather,
540        path,
541    ))
542}
543
544/// Like [`build_aprs_position_weather`] but returns the unencoded
545/// [`Ax25Packet`].
546#[must_use]
547pub fn build_aprs_position_weather_packet(
548    source: &Ax25Address,
549    latitude: f64,
550    longitude: f64,
551    symbol_table: char,
552    weather: &AprsWeather,
553    path: &[Ax25Address],
554) -> Ax25Packet {
555    use std::fmt::Write as _;
556
557    let lat_str = format_aprs_latitude(latitude);
558    let lon_str = format_aprs_longitude(longitude);
559    // Symbol code is always `_` (weather station) for this format.
560    // Wind direction and speed go into the CSE/SPD slot (`DDD/SSS`),
561    // with "..." for missing values.
562    let wind_dir = weather
563        .wind_direction
564        .map_or_else(|| "...".to_owned(), |d| format!("{d:03}"));
565    let wind_spd = weather
566        .wind_speed
567        .map_or_else(|| "...".to_owned(), |s| format!("{s:03}"));
568
569    let mut info = format!("!{lat_str}{symbol_table}{lon_str}_{wind_dir}/{wind_spd}");
570    if let Some(gust) = weather.wind_gust {
571        let _ = write!(info, "g{gust:03}");
572    }
573    if let Some(temp) = weather.temperature {
574        let _ = write!(info, "t{temp:03}");
575    }
576    if let Some(rain) = weather.rain_1h {
577        let _ = write!(info, "r{rain:03}");
578    }
579    if let Some(rain) = weather.rain_24h {
580        let _ = write!(info, "p{rain:03}");
581    }
582    if let Some(rain) = weather.rain_since_midnight {
583        let _ = write!(info, "P{rain:03}");
584    }
585    if let Some(hum) = weather.humidity {
586        let hum_val = if hum == 100 { 0 } else { hum };
587        let _ = write!(info, "h{hum_val:02}");
588    }
589    if let Some(pres) = weather.pressure {
590        let _ = write!(info, "b{pres:05}");
591    }
592
593    ax25_ui_frame(
594        source.clone(),
595        Ax25Address::new(APRS_TOCALL, 0),
596        path.to_vec(),
597        info.into_bytes(),
598    )
599}
600
601/// Like [`build_aprs_weather`] but returns the unencoded [`Ax25Packet`].
602#[must_use]
603pub fn build_aprs_weather_packet(
604    source: &Ax25Address,
605    weather: &AprsWeather,
606    path: &[Ax25Address],
607) -> Ax25Packet {
608    use std::fmt::Write as _;
609
610    let mut info = String::from("_00000000");
611
612    if let Some(dir) = weather.wind_direction {
613        let _ = write!(info, "c{dir:03}");
614    }
615    if let Some(spd) = weather.wind_speed {
616        let _ = write!(info, "s{spd:03}");
617    }
618    if let Some(gust) = weather.wind_gust {
619        let _ = write!(info, "g{gust:03}");
620    }
621    if let Some(temp) = weather.temperature {
622        let _ = write!(info, "t{temp:03}");
623    }
624    if let Some(rain) = weather.rain_1h {
625        let _ = write!(info, "r{rain:03}");
626    }
627    if let Some(rain) = weather.rain_24h {
628        let _ = write!(info, "p{rain:03}");
629    }
630    if let Some(rain) = weather.rain_since_midnight {
631        let _ = write!(info, "P{rain:03}");
632    }
633    if let Some(hum) = weather.humidity {
634        let hum_val = if hum == 100 { 0 } else { hum };
635        let _ = write!(info, "h{hum_val:02}");
636    }
637    if let Some(pres) = weather.pressure {
638        let _ = write!(info, "b{pres:05}");
639    }
640
641    ax25_ui_frame(
642        source.clone(),
643        Ax25Address::new(APRS_TOCALL, 0),
644        path.to_vec(),
645        info.into_bytes(),
646    )
647}
648
649// ---------------------------------------------------------------------------
650// APRS compressed position builder
651// ---------------------------------------------------------------------------
652
653/// Build a KISS-encoded APRS compressed position report.
654///
655/// Compressed format uses base-91 encoding for latitude and longitude,
656/// producing smaller packets than the uncompressed `DDMM.HH` format.
657/// Encoding follows APRS101 Chapter 9.
658///
659/// The compressed body is 13 bytes:
660/// `sym_table(1) YYYY(4) XXXX(4) sym_code(1) cs(1) s(1) t(1)`
661///
662/// Where `cs`, `s`, and `t` are set to indicate no course/speed/altitude
663/// data (space characters).
664///
665/// Returns wire-ready bytes (FEND-delimited KISS frame).
666///
667/// # Parameters
668///
669/// - `source`: The sender's callsign and SSID.
670/// - `latitude`: Decimal degrees, positive = North, negative = South.
671/// - `longitude`: Decimal degrees, positive = East, negative = West.
672/// - `symbol_table`: APRS symbol table character (`/` for primary, `\\` for alternate).
673/// - `symbol_code`: APRS symbol code character (e.g., `>` for car, `-` for house).
674/// - `comment`: Free-form comment text appended after the compressed position.
675/// - `path`: Digipeater path.
676#[must_use]
677pub fn build_aprs_position_compressed(
678    source: &Ax25Address,
679    latitude: f64,
680    longitude: f64,
681    symbol_table: char,
682    symbol_code: char,
683    comment: &str,
684    path: &[Ax25Address],
685) -> Vec<u8> {
686    ax25_to_kiss_wire(&build_aprs_position_compressed_packet(
687        source,
688        latitude,
689        longitude,
690        symbol_table,
691        symbol_code,
692        comment,
693        path,
694    ))
695}
696
697/// Like [`build_aprs_position_compressed`] but returns the unencoded
698/// [`Ax25Packet`].
699#[must_use]
700pub fn build_aprs_position_compressed_packet(
701    source: &Ax25Address,
702    latitude: f64,
703    longitude: f64,
704    symbol_table: char,
705    symbol_code: char,
706    comment: &str,
707    path: &[Ax25Address],
708) -> Ax25Packet {
709    #[expect(
710        clippy::cast_possible_truncation,
711        clippy::cast_sign_loss,
712        reason = "APRS compressed position encoding scales f64 decimal degrees into u32-ranged integers per APRS101 §9"
713    )]
714    let lat_val = (380_926.0 * (90.0 - latitude)) as u32;
715    #[expect(
716        clippy::cast_possible_truncation,
717        clippy::cast_sign_loss,
718        reason = "APRS compressed position encoding scales f64 decimal degrees into u32-ranged integers per APRS101 §9"
719    )]
720    let lon_val = (190_463.0 * (longitude + 180.0)) as u32;
721    let lat_encoded = encode_base91_4(lat_val);
722    let lon_encoded = encode_base91_4(lon_val);
723
724    let mut info = Vec::with_capacity(1 + 13 + comment.len());
725    info.push(b'!');
726    info.push(symbol_table as u8);
727    info.extend_from_slice(&lat_encoded);
728    info.extend_from_slice(&lon_encoded);
729    info.push(symbol_code as u8);
730    info.push(b' '); // cs: no course/speed data
731    info.push(b' ');
732    info.push(b' '); // t: compression type = no data
733    info.extend_from_slice(comment.as_bytes());
734
735    ax25_ui_frame(
736        source.clone(),
737        Ax25Address::new(APRS_TOCALL, 0),
738        path.to_vec(),
739        info,
740    )
741}
742
743// ---------------------------------------------------------------------------
744// APRS status builders
745// ---------------------------------------------------------------------------
746
747/// Build a KISS-encoded APRS status report.
748///
749/// Composes an AX.25 UI frame with the APRS status format:
750/// `>text\r`
751///
752/// Returns wire-ready bytes (FEND-delimited KISS frame).
753///
754/// # Parameters
755///
756/// - `source`: The sender's callsign and SSID.
757/// - `text`: Status text content.
758/// - `path`: Digipeater path.
759#[must_use]
760pub fn build_aprs_status(source: &Ax25Address, text: &str, path: &[Ax25Address]) -> Vec<u8> {
761    ax25_to_kiss_wire(&build_aprs_status_packet(source, text, path))
762}
763
764/// Like [`build_aprs_status`] but returns the unencoded [`Ax25Packet`].
765#[must_use]
766pub fn build_aprs_status_packet(
767    source: &Ax25Address,
768    text: &str,
769    path: &[Ax25Address],
770) -> Ax25Packet {
771    let mut info = Vec::with_capacity(1 + text.len() + 1);
772    info.push(b'>');
773    info.extend_from_slice(text.as_bytes());
774    info.push(b'\r');
775    ax25_ui_frame(
776        source.clone(),
777        Ax25Address::new(APRS_TOCALL, 0),
778        path.to_vec(),
779        info,
780    )
781}
782
783// ---------------------------------------------------------------------------
784// Mic-E builders (APRS101 Chapter 10)
785// ---------------------------------------------------------------------------
786
787/// Build a Mic-E encoded APRS position report for KISS transmission.
788///
789/// Mic-E is the most compact position format and the native format
790/// used by Kenwood HTs including the TH-D75. The latitude is encoded
791/// in the AX.25 destination address, and longitude + speed/course
792/// are in the info field.
793///
794/// Encoding per APRS101 Chapter 10:
795/// - Destination address: 6 chars encoding latitude digits + N/S + lon offset + W/E flags
796/// - Info field: type byte (`0x60` for current Mic-E) + 3 lon bytes + 3 speed/course bytes
797///   + symbol code + symbol table + comment
798///
799/// Returns wire-ready bytes (FEND-delimited KISS frame).
800///
801/// # Parameters
802///
803/// - `source`: The sender's callsign and SSID.
804/// - `latitude`: Decimal degrees, positive = North, negative = South.
805/// - `longitude`: Decimal degrees, positive = East, negative = West.
806/// - `speed_knots`: Speed in knots (0-799).
807/// - `course_deg`: Course in degrees (0-360; 0 = unknown).
808/// - `symbol_table`: APRS symbol table character (`/` for primary, `\\` for alternate).
809/// - `symbol_code`: APRS symbol code character (e.g., `>` for car).
810/// - `comment`: Free-form comment text.
811#[must_use]
812#[expect(
813    clippy::too_many_arguments,
814    reason = "Mic-E wire fields are fundamentally positional"
815)]
816pub fn build_aprs_mice(
817    source: &Ax25Address,
818    latitude: f64,
819    longitude: f64,
820    speed_knots: u16,
821    course_deg: u16,
822    symbol_table: char,
823    symbol_code: char,
824    comment: &str,
825    path: &[Ax25Address],
826) -> Vec<u8> {
827    // Default to Off Duty for backwards compat with the old signature.
828    build_aprs_mice_with_message(
829        source,
830        latitude,
831        longitude,
832        speed_knots,
833        course_deg,
834        MiceMessage::OffDuty,
835        symbol_table,
836        symbol_code,
837        comment,
838        path,
839    )
840}
841
842/// Build a Mic-E encoded APRS position report with a specific
843/// [`MiceMessage`] status code.
844///
845/// Per APRS 1.0.1 §10.1 Table 10, the 8 standard codes are encoded in
846/// the message bits of the first three destination characters. The
847/// other Mic-E encoder entrypoint, [`build_aprs_mice`], uses Off Duty
848/// for backwards compatibility.
849#[must_use]
850#[expect(
851    clippy::too_many_arguments,
852    reason = "Mic-E wire fields are fundamentally positional"
853)]
854pub fn build_aprs_mice_with_message(
855    source: &Ax25Address,
856    latitude: f64,
857    longitude: f64,
858    speed_knots: u16,
859    course_deg: u16,
860    message: MiceMessage,
861    symbol_table: char,
862    symbol_code: char,
863    comment: &str,
864    path: &[Ax25Address],
865) -> Vec<u8> {
866    ax25_to_kiss_wire(&build_aprs_mice_with_message_packet(
867        source,
868        latitude,
869        longitude,
870        speed_knots,
871        course_deg,
872        message,
873        symbol_table,
874        symbol_code,
875        comment,
876        path,
877    ))
878}
879
880/// Like [`build_aprs_mice_with_message`] but returns the unencoded
881/// [`Ax25Packet`] for callers that want to inspect or route it.
882#[must_use]
883#[expect(
884    clippy::too_many_arguments,
885    clippy::too_many_lines,
886    reason = "Mic-E wire fields are fundamentally positional; packing all steps in one function keeps the APRS101 §10 cross-reference readable"
887)]
888pub fn build_aprs_mice_with_message_packet(
889    source: &Ax25Address,
890    latitude: f64,
891    longitude: f64,
892    speed_knots: u16,
893    course_deg: u16,
894    message: MiceMessage,
895    symbol_table: char,
896    symbol_code: char,
897    comment: &str,
898    path: &[Ax25Address],
899) -> Ax25Packet {
900    // Clamp position so the wire fields never overflow.
901    let latitude = latitude.clamp(-90.0, 90.0);
902    let longitude = longitude.clamp(-180.0, 180.0);
903    let north = latitude >= 0.0;
904    let west = longitude < 0.0;
905    let lat_abs = latitude.abs();
906    let lon_abs = longitude.abs();
907
908    // Decompose latitude into digits: DD MM.HH. Clamp the rounding so
909    // hundredths == 100 rolls into minutes correctly.
910    #[expect(
911        clippy::cast_possible_truncation,
912        clippy::cast_sign_loss,
913        reason = "lat_abs is clamped to 0..=90"
914    )]
915    let lat_deg = lat_abs as u32;
916    let lat_min_f = (lat_abs - f64::from(lat_deg)) * 60.0;
917    #[expect(
918        clippy::cast_possible_truncation,
919        clippy::cast_sign_loss,
920        reason = "lat_min_f is in 0..60"
921    )]
922    let lat_min = lat_min_f as u32;
923    let lat_hundredths_f = ((lat_min_f - f64::from(lat_min)) * 100.0).round();
924    #[expect(
925        clippy::cast_possible_truncation,
926        clippy::cast_sign_loss,
927        reason = "lat_hundredths_f rounds to an integer in 0..=100"
928    )]
929    let lat_hundredths = (lat_hundredths_f as u32).min(99);
930
931    // All digit casts are safe: the u32 values are bounded to 0..=9 (or
932    // 0..=99 for hundredths) by the division/min chains above.
933    let d0 = (lat_deg / 10).min(9) as u8;
934    let d1 = (lat_deg % 10) as u8;
935    let d2 = (lat_min / 10).min(9) as u8;
936    let d3 = (lat_min % 10) as u8;
937    let d4 = (lat_hundredths / 10) as u8;
938    let d5 = (lat_hundredths % 10) as u8;
939
940    // Message bits (A, B, C) from the 3-bit index. Per APRS 1.0.1 §10.1
941    // Table 10, bit = 1 (Std1, uppercase P-Y range) when set.
942    let (msg_a, msg_b, msg_c) = mice_message_bits(message);
943
944    // Encode destination address characters. Chars 0-2 carry message
945    // bits A/B/C: if the bit is 1, pick from P-Y; otherwise 0-9.
946    let lon_offset = lon_abs >= 100.0;
947    let dest_chars: [u8; 6] = [
948        if msg_a { b'P' + d0 } else { b'0' + d0 },
949        if msg_b { b'P' + d1 } else { b'0' + d1 },
950        if msg_c { b'P' + d2 } else { b'0' + d2 },
951        if north { b'P' + d3 } else { b'0' + d3 },
952        if lon_offset { b'P' + d4 } else { b'0' + d4 },
953        if west { b'P' + d5 } else { b'0' + d5 },
954    ];
955    // Every byte in `dest_chars` is in the range 0x30-0x59 (P-Y for
956    // custom, 0-9 for standard) by construction above, all valid ASCII.
957    let Ok(dest_callsign) = std::str::from_utf8(&dest_chars) else {
958        unreachable!("Mic-E destination chars are ASCII by construction")
959    };
960
961    // Longitude degrees encoding per APRS 1.0.1 §10.3.3:
962    //   No offset (0-99°):    d = degrees
963    //   Offset set (≥100°):
964    //     100-109°:           d = degrees - 20    (decoder hits 180-189 → subtract 80)
965    //     110-179°:           d = degrees - 100   (decoder passes through)
966    //
967    // Byte on the wire is always d + 28.
968    #[expect(
969        clippy::cast_possible_truncation,
970        clippy::cast_sign_loss,
971        reason = "lon_abs is clamped to 0..=180 so fits u16"
972    )]
973    let lon_deg_raw = lon_abs as u16;
974    #[expect(
975        clippy::cast_possible_truncation,
976        reason = "lon_deg_raw subtraction always yields a value that fits u8 for valid APRS longitudes"
977    )]
978    let d = if lon_offset {
979        if lon_deg_raw >= 110 {
980            (lon_deg_raw - 100) as u8
981        } else {
982            (lon_deg_raw - 20) as u8
983        }
984    } else {
985        lon_deg_raw as u8
986    };
987
988    #[expect(
989        clippy::cast_possible_truncation,
990        clippy::cast_sign_loss,
991        reason = "lon_abs is clamped to 0..=180 so the u32 cast fits"
992    )]
993    let lon_min_f = (lon_abs - f64::from(lon_abs as u32)) * 60.0;
994    #[expect(
995        clippy::cast_possible_truncation,
996        clippy::cast_sign_loss,
997        reason = "lon_min_f is in 0..60"
998    )]
999    let lon_min_int = lon_min_f as u8;
1000    #[expect(
1001        clippy::cast_possible_truncation,
1002        clippy::cast_sign_loss,
1003        reason = "rounded value is 0..=100"
1004    )]
1005    let lon_hundredths = ((lon_min_f - f64::from(lon_min_int)) * 100.0).round() as u8;
1006
1007    // Minutes encoding: if < 10, add 60.
1008    let m = if lon_min_int < 10 {
1009        lon_min_int + 60
1010    } else {
1011        lon_min_int
1012    };
1013
1014    // Speed/course encoding per APRS101.
1015    // SP = speed / 10, remainder from DC.
1016    // DC = (speed % 10) * 10 + course / 100
1017    // SE = course % 100
1018    #[expect(
1019        clippy::cast_possible_truncation,
1020        reason = "speed_knots is u16, speed_knots / 10 fits u8 for typical APRS speeds"
1021    )]
1022    let sp = (speed_knots / 10) as u8;
1023    #[expect(
1024        clippy::cast_possible_truncation,
1025        reason = "combined value stays in u8 range for valid APRS inputs"
1026    )]
1027    let dc = ((speed_knots % 10) * 10 + course_deg / 100) as u8;
1028    // course_deg % 100 is in 0..100 so truncating to u8 is safe.
1029    let se = (course_deg % 100) as u8;
1030
1031    // Build info field.
1032    let mut info = Vec::with_capacity(9 + comment.len());
1033    info.push(0x60); // Current Mic-E data type.
1034    info.push(d + 28);
1035    info.push(m + 28);
1036    info.push(lon_hundredths + 28);
1037    info.push(sp + 28);
1038    info.push(dc + 28);
1039    info.push(se + 28);
1040    info.push(symbol_code as u8);
1041    info.push(symbol_table as u8);
1042    info.extend_from_slice(comment.as_bytes());
1043
1044    ax25_ui_frame(
1045        source.clone(),
1046        Ax25Address::new(dest_callsign, 0),
1047        path.to_vec(),
1048        info,
1049    )
1050}
1051
1052// ---------------------------------------------------------------------------
1053// APRS query response builder
1054// ---------------------------------------------------------------------------
1055
1056/// Build a position query response as a KISS-encoded APRS position report.
1057///
1058/// When a station receives a `?APRSP` or `?APRS?` query, it should respond
1059/// with its current position. This builds that response as a KISS frame
1060/// ready for transmission.
1061#[must_use]
1062pub fn build_query_response_position(
1063    source: &Ax25Address,
1064    lat: f64,
1065    lon: f64,
1066    symbol_table: char,
1067    symbol_code: char,
1068    comment: &str,
1069    path: &[Ax25Address],
1070) -> Vec<u8> {
1071    // A query response is just a normal position report.
1072    build_aprs_position_report(source, lat, lon, symbol_table, symbol_code, comment, path)
1073}
1074
1075// ---------------------------------------------------------------------------
1076// Tests
1077// ---------------------------------------------------------------------------
1078
1079#[cfg(test)]
1080mod tests {
1081    use super::*;
1082    use ax25_codec::parse_ax25;
1083    use kiss_tnc::{CMD_DATA, decode_kiss_frame};
1084
1085    use crate::item::{parse_aprs_item, parse_aprs_object};
1086    use crate::message::parse_aprs_message;
1087    use crate::mic_e::parse_mice_position;
1088    use crate::packet::{AprsData, parse_aprs_data};
1089    use crate::position::parse_aprs_position;
1090    use crate::weather::parse_aprs_weather_positionless;
1091
1092    type TestResult = Result<(), Box<dyn std::error::Error>>;
1093
1094    fn test_source() -> Ax25Address {
1095        Ax25Address::new("N0CALL", 7)
1096    }
1097
1098    /// Default APRS digipeater path: WIDE1-1, WIDE2-1.
1099    fn default_digipeater_path() -> Vec<Ax25Address> {
1100        vec![Ax25Address::new("WIDE1", 1), Ax25Address::new("WIDE2", 1)]
1101    }
1102
1103    // ---- format_aprs_latitude / format_aprs_longitude ----
1104
1105    #[test]
1106    fn format_latitude_north() {
1107        let s = format_aprs_latitude(49.058_333);
1108        // 49 degrees, 3.50 minutes North
1109        assert_eq!(s.len(), 8, "latitude wire field is 8 bytes");
1110        assert!(s.ends_with('N'), "north hemisphere should suffix 'N'");
1111        assert!(s.starts_with("49"), "49-degree prefix preserved");
1112    }
1113
1114    #[test]
1115    fn format_latitude_south() {
1116        let s = format_aprs_latitude(-33.856);
1117        assert!(s.ends_with('S'), "south hemisphere should suffix 'S'");
1118        assert!(s.starts_with("33"), "33-degree prefix preserved");
1119    }
1120
1121    #[test]
1122    fn format_longitude_east() {
1123        let s = format_aprs_longitude(151.209);
1124        assert_eq!(s.len(), 9, "longitude wire field is 9 bytes");
1125        assert!(s.ends_with('E'), "east hemisphere should suffix 'E'");
1126        assert!(s.starts_with("151"), "151-degree prefix preserved");
1127    }
1128
1129    #[test]
1130    fn format_longitude_west() {
1131        let s = format_aprs_longitude(-72.029_166);
1132        assert!(s.ends_with('W'), "west hemisphere should suffix 'W'");
1133        assert!(s.starts_with("072"), "zero-padded 72-degree prefix");
1134    }
1135
1136    // ---- build_aprs_position_report ----
1137
1138    #[test]
1139    fn build_position_report_roundtrip() -> TestResult {
1140        let source = test_source();
1141        let wire = build_aprs_position_report(
1142            &source,
1143            49.058_333,
1144            -72.029_166,
1145            '/',
1146            '-',
1147            "Test",
1148            &default_digipeater_path(),
1149        );
1150
1151        // Decode the KISS frame.
1152        let kiss = decode_kiss_frame(&wire)?;
1153        assert_eq!(kiss.command, CMD_DATA, "KISS command should be data");
1154
1155        // Decode the AX.25 packet.
1156        let packet = parse_ax25(&kiss.data)?;
1157        assert_eq!(packet.source.callsign, "N0CALL");
1158        assert_eq!(packet.source.ssid, 7);
1159        assert_eq!(packet.destination.callsign, "APK005");
1160        assert_eq!(packet.destination.ssid, 0);
1161        assert_eq!(packet.digipeaters.len(), 2);
1162        let digi0 = packet.digipeaters.first().ok_or("digipeater 0 missing")?;
1163        let digi1 = packet.digipeaters.get(1).ok_or("digipeater 1 missing")?;
1164        assert_eq!(digi0.callsign, "WIDE1");
1165        assert_eq!(digi0.ssid, 1);
1166        assert_eq!(digi1.callsign, "WIDE2");
1167        assert_eq!(digi1.ssid, 1);
1168        assert_eq!(packet.control, 0x03);
1169        assert_eq!(packet.protocol, 0xF0);
1170
1171        // Parse the APRS position from the info field.
1172        let pos = parse_aprs_position(&packet.info)?;
1173        assert!((pos.latitude - 49.058_333).abs() < 0.01);
1174        assert!((pos.longitude - (-72.029_166)).abs() < 0.01);
1175        assert_eq!(pos.symbol_table, '/');
1176        assert_eq!(pos.symbol_code, '-');
1177        assert!(pos.comment.contains("Test"), "comment preserved");
1178        Ok(())
1179    }
1180
1181    // ---- build_aprs_object ----
1182
1183    #[test]
1184    fn build_aprs_object_with_real_timestamp() -> TestResult {
1185        let source = test_source();
1186        let wire = build_aprs_object_with_timestamp(
1187            &source,
1188            "EVENT",
1189            true,
1190            AprsTimestamp::DhmZulu {
1191                day: 15,
1192                hour: 14,
1193                minute: 30,
1194            },
1195            35.0,
1196            -97.0,
1197            '/',
1198            '-',
1199            "real",
1200            &default_digipeater_path(),
1201        );
1202        let kiss = decode_kiss_frame(&wire)?;
1203        let packet = parse_ax25(&kiss.data)?;
1204        let obj = parse_aprs_object(&packet.info)?;
1205        assert_eq!(obj.timestamp, "151430z");
1206        Ok(())
1207    }
1208
1209    #[test]
1210    fn build_object_roundtrip() -> TestResult {
1211        let source = test_source();
1212        let wire = build_aprs_object(
1213            &source,
1214            "TORNADO",
1215            true,
1216            49.058_333,
1217            -72.029_166,
1218            '/',
1219            '-',
1220            "Wrn",
1221            &default_digipeater_path(),
1222        );
1223
1224        let kiss = decode_kiss_frame(&wire)?;
1225        let packet = parse_ax25(&kiss.data)?;
1226        assert_eq!(packet.destination.callsign, "APK005");
1227
1228        let obj = parse_aprs_object(&packet.info)?;
1229        assert_eq!(obj.name, "TORNADO");
1230        assert!(obj.live, "object is alive");
1231        assert!((obj.position.latitude - 49.058_333).abs() < 0.01);
1232        assert!((obj.position.longitude - (-72.029_166)).abs() < 0.01);
1233        assert_eq!(obj.position.symbol_table, '/');
1234        assert_eq!(obj.position.symbol_code, '-');
1235        assert!(obj.position.comment.contains("Wrn"), "comment preserved");
1236        Ok(())
1237    }
1238
1239    #[test]
1240    fn build_object_killed() -> TestResult {
1241        let source = test_source();
1242        let wire = build_aprs_object(
1243            &source,
1244            "EVENT",
1245            false,
1246            35.0,
1247            -97.0,
1248            '/',
1249            'E',
1250            "Done",
1251            &default_digipeater_path(),
1252        );
1253
1254        let kiss = decode_kiss_frame(&wire)?;
1255        let packet = parse_ax25(&kiss.data)?;
1256        let obj = parse_aprs_object(&packet.info)?;
1257        assert_eq!(obj.name, "EVENT");
1258        assert!(!obj.live, "killed object should not be live");
1259        Ok(())
1260    }
1261
1262    // ---- build_aprs_message ----
1263
1264    #[test]
1265    fn build_message_roundtrip() -> TestResult {
1266        let source = test_source();
1267        let wire = build_aprs_message(
1268            &source,
1269            "KQ4NIT",
1270            "Hello 73!",
1271            Some("42"),
1272            &default_digipeater_path(),
1273        );
1274
1275        let kiss = decode_kiss_frame(&wire)?;
1276        let packet = parse_ax25(&kiss.data)?;
1277        assert_eq!(packet.destination.callsign, "APK005");
1278
1279        let msg = parse_aprs_message(&packet.info)?;
1280        assert_eq!(msg.addressee, "KQ4NIT");
1281        assert_eq!(msg.text, "Hello 73!");
1282        assert_eq!(msg.message_id, Some("42".to_string()));
1283        Ok(())
1284    }
1285
1286    #[test]
1287    fn build_message_no_id() -> TestResult {
1288        let source = test_source();
1289        let wire = build_aprs_message(
1290            &source,
1291            "W1AW",
1292            "Test msg",
1293            None,
1294            &default_digipeater_path(),
1295        );
1296
1297        let kiss = decode_kiss_frame(&wire)?;
1298        let packet = parse_ax25(&kiss.data)?;
1299        let msg = parse_aprs_message(&packet.info)?;
1300        assert_eq!(msg.addressee, "W1AW");
1301        assert_eq!(msg.text, "Test msg");
1302        assert_eq!(msg.message_id, None);
1303        Ok(())
1304    }
1305
1306    #[test]
1307    fn build_message_pads_short_addressee() -> TestResult {
1308        let source = test_source();
1309        let wire = build_aprs_message(&source, "AB", "Hi", None, &default_digipeater_path());
1310
1311        let kiss = decode_kiss_frame(&wire)?;
1312        let packet = parse_ax25(&kiss.data)?;
1313        // The info field should have the addressee padded to 9 chars.
1314        let info_str = String::from_utf8_lossy(&packet.info);
1315        // Format: :ADDRESSEE:text — addressee is bytes 1..10.
1316        let addressee_field = info_str.get(1..10).ok_or("addressee field missing")?;
1317        assert_eq!(addressee_field, "AB       ");
1318        Ok(())
1319    }
1320
1321    #[test]
1322    fn build_aprs_message_truncates_long_text() -> TestResult {
1323        let source = test_source();
1324        let text = "X".repeat(80);
1325        let wire = build_aprs_message(&source, "N0CALL", &text, None, &default_digipeater_path());
1326
1327        let kiss = decode_kiss_frame(&wire)?;
1328        let packet = parse_ax25(&kiss.data)?;
1329        let msg = parse_aprs_message(&packet.info)?;
1330        assert_eq!(
1331            msg.text.len(),
1332            MAX_APRS_MESSAGE_TEXT_LEN,
1333            "long text should be truncated to the 67-byte spec limit",
1334        );
1335        Ok(())
1336    }
1337
1338    #[test]
1339    fn build_aprs_message_checked_rejects_long_text() {
1340        let source = test_source();
1341        let text = "Y".repeat(80);
1342        let result =
1343            build_aprs_message_checked(&source, "N0CALL", &text, None, &default_digipeater_path());
1344        assert!(
1345            matches!(result, Err(AprsError::MessageTooLong(80))),
1346            "long text should be rejected: {result:?}",
1347        );
1348    }
1349
1350    // ---- build_aprs_item ----
1351
1352    #[test]
1353    fn build_item_live_roundtrip() -> TestResult {
1354        let source = test_source();
1355        let wire = build_aprs_item(
1356            &source,
1357            "MARKER",
1358            true,
1359            49.058_333,
1360            -72.029_166,
1361            '/',
1362            '-',
1363            "Test item",
1364            &default_digipeater_path(),
1365        );
1366
1367        let kiss = decode_kiss_frame(&wire)?;
1368        let packet = parse_ax25(&kiss.data)?;
1369        assert_eq!(packet.destination.callsign, "APK005");
1370
1371        let item = parse_aprs_item(&packet.info)?;
1372        assert_eq!(item.name, "MARKER");
1373        assert!(item.live, "item is alive");
1374        assert!((item.position.latitude - 49.058_333).abs() < 0.01);
1375        assert!((item.position.longitude - (-72.029_166)).abs() < 0.01);
1376        assert_eq!(item.position.symbol_table, '/');
1377        assert_eq!(item.position.symbol_code, '-');
1378        assert!(
1379            item.position.comment.contains("Test item"),
1380            "comment preserved",
1381        );
1382        Ok(())
1383    }
1384
1385    #[test]
1386    fn build_item_killed() -> TestResult {
1387        let source = test_source();
1388        let wire = build_aprs_item(
1389            &source,
1390            "GONE",
1391            false,
1392            35.0,
1393            -97.0,
1394            '/',
1395            'E',
1396            "Removed",
1397            &default_digipeater_path(),
1398        );
1399
1400        let kiss = decode_kiss_frame(&wire)?;
1401        let packet = parse_ax25(&kiss.data)?;
1402        let item = parse_aprs_item(&packet.info)?;
1403        assert_eq!(item.name, "GONE");
1404        assert!(!item.live, "killed item should not be live");
1405        Ok(())
1406    }
1407
1408    // ---- build_aprs_weather ----
1409
1410    #[test]
1411    fn build_weather_full_roundtrip() -> TestResult {
1412        let source = test_source();
1413        let wx = AprsWeather {
1414            wind_direction: Some(180),
1415            wind_speed: Some(10),
1416            wind_gust: Some(25),
1417            temperature: Some(72),
1418            rain_1h: Some(5),
1419            rain_24h: Some(50),
1420            rain_since_midnight: Some(100),
1421            humidity: Some(55),
1422            pressure: Some(10132),
1423        };
1424
1425        let wire = build_aprs_weather(&source, &wx, &default_digipeater_path());
1426        let kiss = decode_kiss_frame(&wire)?;
1427        let packet = parse_ax25(&kiss.data)?;
1428        assert_eq!(packet.destination.callsign, "APK005");
1429
1430        // Parse it back.
1431        let parsed = parse_aprs_weather_positionless(&packet.info)?;
1432        assert_eq!(parsed.wind_direction, Some(180));
1433        assert_eq!(parsed.wind_speed, Some(10));
1434        assert_eq!(parsed.wind_gust, Some(25));
1435        assert_eq!(parsed.temperature, Some(72));
1436        assert_eq!(parsed.rain_1h, Some(5));
1437        assert_eq!(parsed.rain_24h, Some(50));
1438        assert_eq!(parsed.rain_since_midnight, Some(100));
1439        assert_eq!(parsed.humidity, Some(55));
1440        assert_eq!(parsed.pressure, Some(10132));
1441        Ok(())
1442    }
1443
1444    #[test]
1445    fn build_weather_partial_fields() -> TestResult {
1446        let source = test_source();
1447        let wx = AprsWeather {
1448            wind_direction: None,
1449            wind_speed: None,
1450            wind_gust: None,
1451            temperature: Some(32),
1452            rain_1h: None,
1453            rain_24h: None,
1454            rain_since_midnight: None,
1455            humidity: None,
1456            pressure: Some(10200),
1457        };
1458
1459        let wire = build_aprs_weather(&source, &wx, &default_digipeater_path());
1460        let kiss = decode_kiss_frame(&wire)?;
1461        let packet = parse_ax25(&kiss.data)?;
1462
1463        let parsed = parse_aprs_weather_positionless(&packet.info)?;
1464        assert_eq!(parsed.temperature, Some(32));
1465        assert_eq!(parsed.pressure, Some(10200));
1466        assert_eq!(parsed.wind_direction, None);
1467        assert_eq!(parsed.humidity, None);
1468        Ok(())
1469    }
1470
1471    #[test]
1472    fn build_aprs_position_weather_roundtrip() -> TestResult {
1473        let wx = AprsWeather {
1474            wind_direction: Some(90),
1475            wind_speed: Some(10),
1476            wind_gust: Some(15),
1477            temperature: Some(72),
1478            rain_1h: None,
1479            rain_24h: None,
1480            rain_since_midnight: Some(20),
1481            humidity: Some(55),
1482            pressure: Some(10135),
1483        };
1484        let wire = build_aprs_position_weather(
1485            &test_source(),
1486            35.25,
1487            -97.75,
1488            '/',
1489            &wx,
1490            &default_digipeater_path(),
1491        );
1492        let kiss = decode_kiss_frame(&wire)?;
1493        let packet = parse_ax25(&kiss.data)?;
1494        let pos = parse_aprs_position(&packet.info)?;
1495        assert_eq!(pos.symbol_code, '_');
1496        let weather = pos.weather.ok_or("embedded weather missing")?;
1497        assert_eq!(weather.wind_direction, Some(90));
1498        assert_eq!(weather.wind_speed, Some(10));
1499        assert_eq!(weather.wind_gust, Some(15));
1500        assert_eq!(weather.temperature, Some(72));
1501        assert_eq!(weather.humidity, Some(55));
1502        assert_eq!(weather.pressure, Some(10135));
1503        Ok(())
1504    }
1505
1506    #[test]
1507    fn build_weather_humidity_100_encodes_as_00() -> TestResult {
1508        let source = test_source();
1509        let wx = AprsWeather {
1510            wind_direction: None,
1511            wind_speed: None,
1512            wind_gust: None,
1513            temperature: None,
1514            rain_1h: None,
1515            rain_24h: None,
1516            rain_since_midnight: None,
1517            humidity: Some(100),
1518            pressure: None,
1519        };
1520
1521        let wire = build_aprs_weather(&source, &wx, &default_digipeater_path());
1522        let kiss = decode_kiss_frame(&wire)?;
1523        let packet = parse_ax25(&kiss.data)?;
1524
1525        let parsed = parse_aprs_weather_positionless(&packet.info)?;
1526        // APRS encodes humidity 100% as "h00", parser converts back to 100.
1527        assert_eq!(parsed.humidity, Some(100));
1528        Ok(())
1529    }
1530
1531    // ---- build_aprs_position_compressed ----
1532
1533    #[test]
1534    fn build_compressed_position_round_trip() -> TestResult {
1535        let source = test_source();
1536        let wire = build_aprs_position_compressed(
1537            &source,
1538            35.3,
1539            -84.233,
1540            '/',
1541            '>',
1542            "test",
1543            &default_digipeater_path(),
1544        );
1545        let kiss = decode_kiss_frame(&wire)?;
1546        let packet = parse_ax25(&kiss.data)?;
1547        assert_eq!(packet.destination.callsign, "APK005");
1548        assert_eq!(packet.control, 0x03);
1549        assert_eq!(packet.protocol, 0xF0);
1550
1551        // Parse it back through the existing compressed parser.
1552        let data = parse_aprs_data(&packet.info)?;
1553        let AprsData::Position(pos) = data else {
1554            return Err(format!("expected Position, got {data:?}").into());
1555        };
1556        // Compressed encoding has some rounding; check within tolerance.
1557        assert!((pos.latitude - 35.3).abs() < 0.01, "lat: {}", pos.latitude);
1558        assert!(
1559            (pos.longitude - (-84.233)).abs() < 0.01,
1560            "lon: {}",
1561            pos.longitude,
1562        );
1563        assert_eq!(pos.symbol_table, '/');
1564        assert_eq!(pos.symbol_code, '>');
1565        assert!(pos.comment.contains("test"), "comment preserved");
1566        Ok(())
1567    }
1568
1569    #[test]
1570    fn build_compressed_position_equator_prime_meridian() -> TestResult {
1571        let source = test_source();
1572        let wire = build_aprs_position_compressed(
1573            &source,
1574            0.0,
1575            0.0,
1576            '/',
1577            '-',
1578            "",
1579            &default_digipeater_path(),
1580        );
1581        let kiss = decode_kiss_frame(&wire)?;
1582        let packet = parse_ax25(&kiss.data)?;
1583
1584        let data = parse_aprs_data(&packet.info)?;
1585        let AprsData::Position(pos) = data else {
1586            return Err(format!("expected Position, got {data:?}").into());
1587        };
1588        assert!(pos.latitude.abs() < 0.01, "lat: {}", pos.latitude);
1589        assert!(pos.longitude.abs() < 0.01, "lon: {}", pos.longitude);
1590        Ok(())
1591    }
1592
1593    #[test]
1594    fn build_compressed_position_southern_hemisphere() -> TestResult {
1595        let source = test_source();
1596        let wire = build_aprs_position_compressed(
1597            &source,
1598            -33.86,
1599            151.21,
1600            '/',
1601            '>',
1602            "sydney",
1603            &default_digipeater_path(),
1604        );
1605        let kiss = decode_kiss_frame(&wire)?;
1606        let packet = parse_ax25(&kiss.data)?;
1607
1608        let data = parse_aprs_data(&packet.info)?;
1609        let AprsData::Position(pos) = data else {
1610            return Err(format!("expected Position, got {data:?}").into());
1611        };
1612        assert!(
1613            (pos.latitude - (-33.86)).abs() < 0.01,
1614            "lat: {}",
1615            pos.latitude,
1616        );
1617        assert!(
1618            (pos.longitude - 151.21).abs() < 0.01,
1619            "lon: {}",
1620            pos.longitude,
1621        );
1622        Ok(())
1623    }
1624
1625    #[test]
1626    fn base91_encoding_known_value() {
1627        // APRS101 example: 90 degrees latitude encodes as "!!!!".
1628        let encoded = encode_base91_4(0);
1629        assert_eq!(encoded, [b'!', b'!', b'!', b'!']);
1630    }
1631
1632    // ---- build_aprs_status ----
1633
1634    #[test]
1635    fn build_status_round_trip() -> TestResult {
1636        let source = test_source();
1637        let wire = build_aprs_status(&source, "On the air in FM18", &default_digipeater_path());
1638        let kiss = decode_kiss_frame(&wire)?;
1639        let packet = parse_ax25(&kiss.data)?;
1640        assert_eq!(packet.destination.callsign, "APK005");
1641
1642        let data = parse_aprs_data(&packet.info)?;
1643        let AprsData::Status(status) = data else {
1644            return Err(format!("expected Status, got {data:?}").into());
1645        };
1646        assert_eq!(status.text, "On the air in FM18");
1647        Ok(())
1648    }
1649
1650    #[test]
1651    fn build_status_empty_text() -> TestResult {
1652        let source = test_source();
1653        let wire = build_aprs_status(&source, "", &default_digipeater_path());
1654        let kiss = decode_kiss_frame(&wire)?;
1655        let packet = parse_ax25(&kiss.data)?;
1656
1657        let data = parse_aprs_data(&packet.info)?;
1658        let AprsData::Status(status) = data else {
1659            return Err(format!("expected Status, got {data:?}").into());
1660        };
1661        assert_eq!(status.text, "");
1662        Ok(())
1663    }
1664
1665    #[test]
1666    fn build_status_info_field_format() -> TestResult {
1667        let source = test_source();
1668        let wire = build_aprs_status(&source, "Hello", &default_digipeater_path());
1669        let kiss = decode_kiss_frame(&wire)?;
1670        let packet = parse_ax25(&kiss.data)?;
1671
1672        // Info field should be: >Hello\r
1673        assert_eq!(packet.info.first().copied(), Some(b'>'));
1674        assert_eq!(packet.info.get(1..6), Some(b"Hello".as_slice()));
1675        assert_eq!(packet.info.get(6).copied(), Some(b'\r'));
1676        Ok(())
1677    }
1678
1679    // ---- build_aprs_mice ----
1680
1681    #[test]
1682    fn build_mice_roundtrip_oklahoma() -> TestResult {
1683        // 35.258 N, 97.755 W — matches the existing parse_mice test case.
1684        let source = test_source();
1685        let wire = build_aprs_mice(
1686            &source,
1687            35.258,
1688            -97.755,
1689            121,
1690            212,
1691            '/',
1692            '>',
1693            "test",
1694            &default_digipeater_path(),
1695        );
1696
1697        let kiss = decode_kiss_frame(&wire)?;
1698        let packet = parse_ax25(&kiss.data)?;
1699
1700        // Destination should encode the latitude.
1701        let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1702        assert!((pos.latitude - 35.258).abs() < 0.02, "lat={}", pos.latitude);
1703        assert!(
1704            (pos.longitude - (-97.755)).abs() < 0.02,
1705            "lon={}",
1706            pos.longitude,
1707        );
1708        assert_eq!(pos.symbol_table, '/');
1709        assert_eq!(pos.symbol_code, '>');
1710        assert!(pos.comment.contains("test"), "comment preserved");
1711        Ok(())
1712    }
1713
1714    #[test]
1715    fn build_mice_roundtrip_north_east() -> TestResult {
1716        // 51.5 N, 0.1 W (London area)
1717        let source = test_source();
1718        let wire = build_aprs_mice(
1719            &source,
1720            51.5,
1721            -0.1,
1722            0,
1723            0,
1724            '/',
1725            '-',
1726            "",
1727            &default_digipeater_path(),
1728        );
1729
1730        let kiss = decode_kiss_frame(&wire)?;
1731        let packet = parse_ax25(&kiss.data)?;
1732        let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1733        assert!((pos.latitude - 51.5).abs() < 0.02, "lat={}", pos.latitude);
1734        assert!(
1735            (pos.longitude - (-0.1)).abs() < 0.02,
1736            "lon={}",
1737            pos.longitude,
1738        );
1739        Ok(())
1740    }
1741
1742    #[test]
1743    fn build_mice_roundtrip_southern_hemisphere() -> TestResult {
1744        // -33.86 S, 151.21 E (Sydney)
1745        let source = test_source();
1746        let wire = build_aprs_mice(
1747            &source,
1748            -33.86,
1749            151.21,
1750            50,
1751            180,
1752            '/',
1753            '>',
1754            "sydney",
1755            &default_digipeater_path(),
1756        );
1757
1758        let kiss = decode_kiss_frame(&wire)?;
1759        let packet = parse_ax25(&kiss.data)?;
1760        let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1761        assert!(
1762            (pos.latitude - (-33.86)).abs() < 0.02,
1763            "lat={}",
1764            pos.latitude,
1765        );
1766        assert!(
1767            (pos.longitude - 151.21).abs() < 0.02,
1768            "lon={}",
1769            pos.longitude,
1770        );
1771        Ok(())
1772    }
1773
1774    #[test]
1775    fn build_mice_speed_course_roundtrip() -> TestResult {
1776        let source = test_source();
1777        let wire = build_aprs_mice(
1778            &source,
1779            35.0,
1780            -97.0,
1781            55,
1782            270,
1783            '/',
1784            '>',
1785            "",
1786            &default_digipeater_path(),
1787        );
1788
1789        let kiss = decode_kiss_frame(&wire)?;
1790        let packet = parse_ax25(&kiss.data)?;
1791        let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1792        assert_eq!(pos.speed_knots, Some(55));
1793        assert_eq!(pos.course_degrees, Some(270));
1794        Ok(())
1795    }
1796
1797    #[test]
1798    fn build_mice_zero_speed_course() -> TestResult {
1799        let source = test_source();
1800        let wire = build_aprs_mice(
1801            &source,
1802            40.0,
1803            -74.0,
1804            0,
1805            0,
1806            '/',
1807            '>',
1808            "",
1809            &default_digipeater_path(),
1810        );
1811
1812        let kiss = decode_kiss_frame(&wire)?;
1813        let packet = parse_ax25(&kiss.data)?;
1814        let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1815        assert_eq!(pos.speed_knots, Some(0));
1816        // Course 0 = unknown → None in the decoder.
1817        assert_eq!(pos.course_degrees, None);
1818        Ok(())
1819    }
1820
1821    #[test]
1822    fn build_mice_high_longitude() -> TestResult {
1823        // 35.0 N, 140.0 E (Tokyo area)
1824        let source = test_source();
1825        let wire = build_aprs_mice(
1826            &source,
1827            35.0,
1828            140.0,
1829            10,
1830            90,
1831            '/',
1832            '>',
1833            "",
1834            &default_digipeater_path(),
1835        );
1836
1837        let kiss = decode_kiss_frame(&wire)?;
1838        let packet = parse_ax25(&kiss.data)?;
1839        let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1840        assert!((pos.latitude - 35.0).abs() < 0.02, "lat={}", pos.latitude);
1841        assert!(
1842            (pos.longitude - 140.0).abs() < 0.02,
1843            "lon={}",
1844            pos.longitude,
1845        );
1846        Ok(())
1847    }
1848
1849    #[test]
1850    fn build_mice_with_message_roundtrip() -> TestResult {
1851        // Encode each standard message code, decode it back, verify.
1852        let cases = [
1853            MiceMessage::OffDuty,
1854            MiceMessage::EnRoute,
1855            MiceMessage::InService,
1856            MiceMessage::Returning,
1857            MiceMessage::Committed,
1858            MiceMessage::Special,
1859            MiceMessage::Priority,
1860            MiceMessage::Emergency,
1861        ];
1862        for msg in cases {
1863            let source = test_source();
1864            let wire = build_aprs_mice_with_message(
1865                &source,
1866                35.25,
1867                -97.75,
1868                10,
1869                90,
1870                msg,
1871                '/',
1872                '>',
1873                "",
1874                &default_digipeater_path(),
1875            );
1876            let kiss = decode_kiss_frame(&wire)?;
1877            let packet = parse_ax25(&kiss.data)?;
1878            let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1879            assert_eq!(pos.mice_message, Some(msg), "round trip for {msg:?}");
1880        }
1881        Ok(())
1882    }
1883
1884    #[test]
1885    fn build_mice_lon_100_109() -> TestResult {
1886        // 35.0 N, 105.5 W (New Mexico)
1887        let source = test_source();
1888        let wire = build_aprs_mice(
1889            &source,
1890            35.0,
1891            -105.5,
1892            0,
1893            0,
1894            '/',
1895            '>',
1896            "",
1897            &default_digipeater_path(),
1898        );
1899
1900        let kiss = decode_kiss_frame(&wire)?;
1901        let packet = parse_ax25(&kiss.data)?;
1902        let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1903        assert!(
1904            (pos.longitude - (-105.5)).abs() < 0.02,
1905            "lon={}",
1906            pos.longitude,
1907        );
1908        Ok(())
1909    }
1910
1911    // ---- build_query_response_position ----
1912
1913    #[test]
1914    fn build_query_response_roundtrip() -> TestResult {
1915        let source = test_source();
1916        let wire = build_query_response_position(
1917            &source,
1918            35.258,
1919            -97.755,
1920            '/',
1921            '>',
1922            "QRY resp",
1923            &default_digipeater_path(),
1924        );
1925        let kiss = decode_kiss_frame(&wire)?;
1926        let packet = parse_ax25(&kiss.data)?;
1927        let data = parse_aprs_data(&packet.info)?;
1928        let AprsData::Position(pos) = data else {
1929            return Err(format!("expected Position, got {data:?}").into());
1930        };
1931        assert!((pos.latitude - 35.258).abs() < 0.01);
1932        assert!((pos.longitude - (-97.755)).abs() < 0.01);
1933        assert!(pos.comment.contains("QRY resp"), "comment preserved");
1934        Ok(())
1935    }
1936}