Skip to main content

libaprs_engine/
encoder.rs

1//! Owned-byte APRS packet construction helpers.
2//!
3//! Encoders validate conservative packet shape before emitting bytes. They do
4//! not transmit, log, normalize, or display packet contents.
5
6use crate::{
7    is_ax25_like_path_component, is_ax25_like_source, is_latitude, is_longitude,
8    is_printable_ascii, is_symbol_table_identifier, is_timestamp, MAX_PACKET_LEN,
9};
10
11/// APRS encoder failure.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum EncodeError {
14    /// The path list is empty.
15    EmptyPath,
16    /// Source or path address bytes are not conservative AX.25-like metadata.
17    InvalidAddress,
18    /// Source or path address bytes contain lowercase ASCII.
19    LowercaseAddress,
20    /// A payload or semantic field is invalid for the selected packet type.
21    InvalidField,
22    /// Packet payload is empty.
23    EmptyPayload,
24    /// Encoded packet would exceed [`MAX_PACKET_LEN`].
25    OversizedPacket,
26}
27
28impl EncodeError {
29    /// Stable machine-readable error code.
30    #[must_use]
31    pub const fn code(self) -> &'static str {
32        match self {
33            Self::EmptyPath => "encode.empty_path",
34            Self::InvalidAddress => "encode.invalid_address",
35            Self::LowercaseAddress => "encode.lowercase_address",
36            Self::InvalidField => "encode.invalid_field",
37            Self::EmptyPayload => "encode.empty_payload",
38            Self::OversizedPacket => "encode.oversized_packet",
39        }
40    }
41}
42
43impl std::fmt::Display for EncodeError {
44    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        formatter.write_str(self.code())
46    }
47}
48
49impl std::error::Error for EncodeError {}
50
51/// Uncompressed position fields for packet construction.
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub struct UncompressedPositionEncoding<'a> {
54    /// Whether to use the APRS messaging-capable data type identifier.
55    pub messaging: bool,
56    /// Latitude bytes in APRS `DDMM.mmN/S` form.
57    pub latitude: &'a [u8],
58    /// Symbol table identifier byte.
59    pub symbol_table: u8,
60    /// Longitude bytes in APRS `DDDMM.mmE/W` form.
61    pub longitude: &'a [u8],
62    /// Symbol code byte.
63    pub symbol_code: u8,
64    /// Optional comment bytes after the symbol code.
65    pub comment: &'a [u8],
66}
67
68/// Object fields for packet construction.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub struct ObjectEncoding<'a> {
71    /// Nine-byte object name.
72    pub name: &'a [u8],
73    /// Whether the object is live (`*`) rather than killed (`_`).
74    pub live: bool,
75    /// Seven-byte object timestamp.
76    pub timestamp: &'a [u8],
77    /// Object body bytes, usually a position and comment.
78    pub body: &'a [u8],
79}
80
81/// Item fields for packet construction.
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub struct ItemEncoding<'a> {
84    /// Item name bytes, one through nine printable bytes.
85    pub name: &'a [u8],
86    /// Whether the item is live (`!`) rather than killed (`_`).
87    pub live: bool,
88    /// Item body bytes, usually a position and comment.
89    pub body: &'a [u8],
90}
91
92/// Telemetry metadata packet kind for packet construction.
93#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94pub enum TelemetryMetadataEncodingKind {
95    /// `PARM.` telemetry parameter names.
96    Parameters,
97    /// `UNIT.` telemetry unit names.
98    Units,
99    /// `EQNS.` telemetry equation coefficients.
100    Equations,
101    /// `BITS.` telemetry bit labels and project title.
102    BitSense,
103}
104
105impl TelemetryMetadataEncodingKind {
106    fn addressee(self) -> &'static [u8; 9] {
107        match self {
108            Self::Parameters => b"PARM.    ",
109            Self::Units => b"UNIT.    ",
110            Self::Equations => b"EQNS.    ",
111            Self::BitSense => b"BITS.    ",
112        }
113    }
114}
115
116/// Encodes an arbitrary APRS packet payload into `source>path:payload` bytes.
117pub fn encode_packet(
118    source: &[u8],
119    path: &[&[u8]],
120    payload: &[u8],
121) -> Result<Vec<u8>, EncodeError> {
122    if payload.is_empty() {
123        return Err(EncodeError::EmptyPayload);
124    }
125    ensure_packet_shape(source, path, payload.len())?;
126
127    let mut encoded = Vec::with_capacity(packet_len(source, path, payload.len())?);
128    encoded.extend_from_slice(source);
129    encoded.push(b'>');
130    encoded.extend_from_slice(path[0]);
131    for component in &path[1..] {
132        encoded.push(b',');
133        encoded.extend_from_slice(component);
134    }
135    encoded.push(b':');
136    encoded.extend_from_slice(payload);
137
138    Ok(encoded)
139}
140
141/// Encodes a status packet.
142pub fn encode_status(source: &[u8], path: &[&[u8]], text: &[u8]) -> Result<Vec<u8>, EncodeError> {
143    ensure_packet_shape(source, path, 1usize.saturating_add(text.len()))?;
144    let mut payload = Vec::with_capacity(text.len().saturating_add(1));
145    payload.push(b'>');
146    payload.extend_from_slice(text);
147    encode_packet(source, path, &payload)
148}
149
150/// Encodes an uncompressed position packet.
151pub fn encode_uncompressed_position(
152    source: &[u8],
153    path: &[&[u8]],
154    position: UncompressedPositionEncoding<'_>,
155) -> Result<Vec<u8>, EncodeError> {
156    if !is_latitude(position.latitude)
157        || !is_symbol_table_identifier(position.symbol_table)
158        || !is_longitude(position.longitude)
159        || !is_printable_ascii(position.symbol_code)
160    {
161        return Err(EncodeError::InvalidField);
162    }
163
164    ensure_packet_shape(source, path, 20usize.saturating_add(position.comment.len()))?;
165    let mut payload = Vec::with_capacity(20usize.saturating_add(position.comment.len()));
166    payload.push(if position.messaging { b'=' } else { b'!' });
167    payload.extend_from_slice(position.latitude);
168    payload.push(position.symbol_table);
169    payload.extend_from_slice(position.longitude);
170    payload.push(position.symbol_code);
171    payload.extend_from_slice(position.comment);
172    encode_packet(source, path, &payload)
173}
174
175/// Encodes an APRS message, acknowledgement, rejection, bulletin, or
176/// announcement payload.
177pub fn encode_message(
178    source: &[u8],
179    path: &[&[u8]],
180    addressee: &[u8],
181    text: &[u8],
182    id: Option<&[u8]>,
183) -> Result<Vec<u8>, EncodeError> {
184    if !is_fixed_printable(addressee, 9)
185        || id.is_some_and(|message_id| !is_valid_message_id(message_id))
186    {
187        return Err(EncodeError::InvalidField);
188    }
189
190    let id_len = id.map_or(0, |message_id| 1usize.saturating_add(message_id.len()));
191    let payload_len = 11usize.saturating_add(text.len()).saturating_add(id_len);
192    ensure_packet_shape(source, path, payload_len)?;
193
194    let mut payload = Vec::with_capacity(payload_len);
195    payload.push(b':');
196    payload.extend_from_slice(addressee);
197    payload.push(b':');
198    payload.extend_from_slice(text);
199    if let Some(id) = id {
200        payload.push(b'{');
201        payload.extend_from_slice(id);
202    }
203
204    encode_packet(source, path, &payload)
205}
206
207/// Encodes a message acknowledgement packet.
208pub fn encode_ack(
209    source: &[u8],
210    path: &[&[u8]],
211    addressee: &[u8],
212    message_id: &[u8],
213) -> Result<Vec<u8>, EncodeError> {
214    encode_message_response(source, path, addressee, b"ack", message_id)
215}
216
217/// Encodes a message rejection packet.
218pub fn encode_reject(
219    source: &[u8],
220    path: &[&[u8]],
221    addressee: &[u8],
222    message_id: &[u8],
223) -> Result<Vec<u8>, EncodeError> {
224    encode_message_response(source, path, addressee, b"rej", message_id)
225}
226
227/// Encodes a bulletin packet with a one-byte numeric bulletin identifier.
228pub fn encode_bulletin(
229    source: &[u8],
230    path: &[&[u8]],
231    bulletin_id: u8,
232    text: &[u8],
233) -> Result<Vec<u8>, EncodeError> {
234    if !bulletin_id.is_ascii_digit() {
235        return Err(EncodeError::InvalidField);
236    }
237
238    let mut addressee = *b"BLN      ";
239    addressee[3] = bulletin_id;
240    encode_message(source, path, &addressee, text, None)
241}
242
243/// Encodes an announcement packet with a one-byte uppercase announcement
244/// identifier.
245pub fn encode_announcement(
246    source: &[u8],
247    path: &[&[u8]],
248    announcement_id: u8,
249    text: &[u8],
250) -> Result<Vec<u8>, EncodeError> {
251    if !announcement_id.is_ascii_uppercase() {
252        return Err(EncodeError::InvalidField);
253    }
254
255    let mut addressee = *b"BLN      ";
256    addressee[3] = announcement_id;
257    encode_message(source, path, &addressee, text, None)
258}
259
260/// Encodes a telemetry report packet.
261pub fn encode_telemetry(
262    source: &[u8],
263    path: &[&[u8]],
264    sequence: u16,
265    analog: [u16; 5],
266    digital: Option<[bool; 8]>,
267) -> Result<Vec<u8>, EncodeError> {
268    let payload_len = if digital.is_some() { 34 } else { 25 };
269    ensure_packet_shape(source, path, payload_len)?;
270
271    let mut payload = Vec::with_capacity(payload_len);
272    payload.extend_from_slice(b"T#");
273    push_three_digits(&mut payload, sequence)?;
274    for value in analog {
275        payload.push(b',');
276        push_three_digits(&mut payload, value)?;
277    }
278    if let Some(digital) = digital {
279        payload.push(b',');
280        for bit in digital {
281            payload.push(if bit { b'1' } else { b'0' });
282        }
283    }
284
285    encode_packet(source, path, &payload)
286}
287
288/// Encodes a telemetry metadata packet.
289pub fn encode_telemetry_metadata(
290    source: &[u8],
291    path: &[&[u8]],
292    kind: TelemetryMetadataEncodingKind,
293    body: &[u8],
294) -> Result<Vec<u8>, EncodeError> {
295    if body.is_empty() {
296        return Err(EncodeError::InvalidField);
297    }
298
299    encode_message(source, path, kind.addressee(), body, None)
300}
301
302/// Encodes an object packet.
303pub fn encode_object(
304    source: &[u8],
305    path: &[&[u8]],
306    object: ObjectEncoding<'_>,
307) -> Result<Vec<u8>, EncodeError> {
308    if !is_fixed_printable(object.name, 9)
309        || !is_timestamp(object.timestamp)
310        || object.body.is_empty()
311    {
312        return Err(EncodeError::InvalidField);
313    }
314
315    ensure_packet_shape(source, path, 18usize.saturating_add(object.body.len()))?;
316    let mut payload = Vec::with_capacity(18usize.saturating_add(object.body.len()));
317    payload.push(b';');
318    payload.extend_from_slice(object.name);
319    payload.push(if object.live { b'*' } else { b'_' });
320    payload.extend_from_slice(object.timestamp);
321    payload.extend_from_slice(object.body);
322    encode_packet(source, path, &payload)
323}
324
325/// Encodes an item packet.
326pub fn encode_item(
327    source: &[u8],
328    path: &[&[u8]],
329    item: ItemEncoding<'_>,
330) -> Result<Vec<u8>, EncodeError> {
331    if item.name.is_empty()
332        || item.name.len() > 9
333        || !item
334            .name
335            .iter()
336            .all(|byte| is_printable_ascii(*byte) && !matches!(*byte, b'!' | b'_'))
337        || item.body.is_empty()
338    {
339        return Err(EncodeError::InvalidField);
340    }
341
342    ensure_packet_shape(
343        source,
344        path,
345        2usize.saturating_add(item.name.len().saturating_add(item.body.len())),
346    )?;
347    let mut payload =
348        Vec::with_capacity(2usize.saturating_add(item.name.len().saturating_add(item.body.len())));
349    payload.push(b')');
350    payload.extend_from_slice(item.name);
351    payload.push(if item.live { b'!' } else { b'_' });
352    payload.extend_from_slice(item.body);
353    encode_packet(source, path, &payload)
354}
355
356fn validate_addresses(source: &[u8], path: &[&[u8]]) -> Result<(), EncodeError> {
357    if path.is_empty() {
358        return Err(EncodeError::EmptyPath);
359    }
360
361    if source.iter().any(u8::is_ascii_lowercase)
362        || path
363            .iter()
364            .any(|component| component.iter().any(u8::is_ascii_lowercase))
365    {
366        return Err(EncodeError::LowercaseAddress);
367    }
368
369    if !is_ax25_like_source(source)
370        || !path
371            .iter()
372            .all(|component| is_ax25_like_path_component(component))
373    {
374        return Err(EncodeError::InvalidAddress);
375    }
376
377    Ok(())
378}
379
380fn ensure_packet_shape(
381    source: &[u8],
382    path: &[&[u8]],
383    payload_len: usize,
384) -> Result<(), EncodeError> {
385    validate_addresses(source, path)?;
386    if packet_len(source, path, payload_len)? > MAX_PACKET_LEN {
387        return Err(EncodeError::OversizedPacket);
388    }
389    Ok(())
390}
391
392fn packet_len(source: &[u8], path: &[&[u8]], payload_len: usize) -> Result<usize, EncodeError> {
393    let path_len = path.iter().try_fold(0usize, |accumulator, component| {
394        accumulator.checked_add(component.len())
395    });
396    let Some(path_len) = path_len else {
397        return Err(EncodeError::OversizedPacket);
398    };
399
400    source
401        .len()
402        .checked_add(1)
403        .and_then(|len| len.checked_add(path_len))
404        .and_then(|len| len.checked_add(path.len().saturating_sub(1)))
405        .and_then(|len| len.checked_add(1))
406        .and_then(|len| len.checked_add(payload_len))
407        .ok_or(EncodeError::OversizedPacket)
408}
409
410fn is_fixed_printable(value: &[u8], len: usize) -> bool {
411    value.len() == len && value.iter().all(|byte| is_printable_ascii(*byte))
412}
413
414fn is_valid_message_id(value: &[u8]) -> bool {
415    (1..=5).contains(&value.len())
416        && value
417            .iter()
418            .all(|byte| is_printable_ascii(*byte) && *byte != b'{')
419}
420
421fn encode_message_response(
422    source: &[u8],
423    path: &[&[u8]],
424    addressee: &[u8],
425    prefix: &[u8; 3],
426    message_id: &[u8],
427) -> Result<Vec<u8>, EncodeError> {
428    if !is_valid_message_id(message_id) {
429        return Err(EncodeError::InvalidField);
430    }
431
432    let mut text = Vec::with_capacity(3usize.saturating_add(message_id.len()));
433    text.extend_from_slice(prefix);
434    text.extend_from_slice(message_id);
435    encode_message(source, path, addressee, &text, None)
436}
437
438fn push_three_digits(output: &mut Vec<u8>, value: u16) -> Result<(), EncodeError> {
439    if value > 999 {
440        return Err(EncodeError::InvalidField);
441    }
442
443    output.push(b'0' + u8::try_from(value / 100).map_err(|_| EncodeError::InvalidField)?);
444    output.push(b'0' + u8::try_from((value / 10) % 10).map_err(|_| EncodeError::InvalidField)?);
445    output.push(b'0' + u8::try_from(value % 10).map_err(|_| EncodeError::InvalidField)?);
446    Ok(())
447}