umsh_ulcp/
meta.rs

1//! `STR_PHY_RAW` metadata envelopes.
2//!
3//! The metadata trails the packet data in `CMD_STR_SEND` /
4//! `CMD_STR_RECV` payloads and may be absent entirely; decoding an
5//! empty slice yields the defaults.
6
7use core::num::NonZeroU8;
8
9/// `TX_POWER` value requesting the radio's default power.
10pub const TX_POWER_DEFAULT: i8 = 0x7F;
11/// `TX_POWER` value requesting maximum power.
12pub const TX_POWER_MAX: i8 = 0x7E;
13
14/// `TX_FLAGS` bit: do not use CCA (or the equivalent LoRa mechanism).
15pub const TX_FLAG_NOCCA: u8 = 1 << 0;
16/// `TX_FLAGS` bit: send even if it would exceed the duty-cycle limit.
17pub const TX_FLAG_NODUTY: u8 = 1 << 1;
18
19/// `RX_FLAGS` bit: the frame was held in the inbound queue and is being
20/// delivered by `CMD_QUEUE_DRAIN`.
21pub const RX_FLAG_BUFFERED: u8 = 1 << 0;
22/// `RX_FLAGS` bit: the device already transmitted a MAC ack for this frame
23/// on the host's behalf; the host must not ack it again.
24pub const RX_FLAG_ACKED: u8 = 1 << 1;
25/// `RX_FLAGS` bit: the device transmitted this frame itself and is
26/// delivering a copy. `RX_RSSI` and `RX_SNR` carry their unsupported
27/// sentinels, since a transmitter measures nothing.
28pub const RX_FLAG_SELF_TX: u8 = 1 << 2;
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum MetaError {
32    /// The metadata was present but shorter than its wire format.
33    Truncated,
34    /// The output buffer cannot hold the encoded metadata.
35    BufferTooSmall,
36}
37
38/// Transmit metadata for `Send` on `STR_PHY_RAW`.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub struct TxMeta {
41    /// Transmit power override in dBm, or one of [`TX_POWER_DEFAULT`]
42    /// and [`TX_POWER_MAX`].
43    pub power: i8,
44    /// Combination of the `TX_FLAG_*` bits.
45    pub flags: u8,
46}
47
48impl Default for TxMeta {
49    fn default() -> Self {
50        Self {
51            power: TX_POWER_DEFAULT,
52            flags: 0,
53        }
54    }
55}
56
57impl TxMeta {
58    pub const WIRE_LEN: usize = 2;
59
60    pub fn encode(self, out: &mut [u8]) -> Result<usize, MetaError> {
61        let [power, flags, ..] = out else {
62            return Err(MetaError::BufferTooSmall);
63        };
64        *power = self.power as u8;
65        *flags = self.flags;
66        Ok(Self::WIRE_LEN)
67    }
68
69    pub fn decode(input: &[u8]) -> Result<Self, MetaError> {
70        match input {
71            [] => Ok(Self::default()),
72            [power, flags, ..] => Ok(Self {
73                power: *power as i8,
74                flags: *flags,
75            }),
76            _ => Err(MetaError::Truncated),
77        }
78    }
79}
80
81/// Receive metadata for `Recv` on `STR_PHY_RAW`.
82///
83/// Each field has a wire-level "not supported" sentinel, mapped to
84/// `None` here.
85#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
86pub struct RxMeta {
87    /// RSSI in dBm. On the wire this is the negated RSSI as a `u8`,
88    /// with `0xFF` meaning unsupported, so only values in `-254..=0`
89    /// are representable; `encode` clamps to that range.
90    pub rssi_dbm: Option<i16>,
91    /// Link-quality indicator, 1 (worst) to 255 (perfect).
92    pub lqi: Option<NonZeroU8>,
93    /// Signal-to-noise ratio in centibels. The wire sentinel `0x8000`
94    /// (`i16::MIN`, -3276.8 dB) means unsupported. No real link can
95    /// report that value, so every genuine measurement round-trips
96    /// without distortion.
97    pub snr_cb: Option<i16>,
98}
99
100impl RxMeta {
101    pub const WIRE_LEN: usize = 4;
102
103    pub fn encode(self, out: &mut [u8]) -> Result<usize, MetaError> {
104        if out.len() < Self::WIRE_LEN {
105            return Err(MetaError::BufferTooSmall);
106        }
107        out[0] = match self.rssi_dbm {
108            None => 0xFF,
109            Some(rssi) => (-rssi).clamp(0, 254) as u8,
110        };
111        out[1] = self.lqi.map(NonZeroU8::get).unwrap_or(0);
112        // `i16::MIN` is the "unsupported" sentinel; it is physically
113        // unreachable as a real SNR, so no genuine reading needs nudging.
114        let snr = self.snr_cb.unwrap_or(i16::MIN);
115        out[2..4].copy_from_slice(&snr.to_le_bytes());
116        Ok(Self::WIRE_LEN)
117    }
118
119    pub fn decode(input: &[u8]) -> Result<Self, MetaError> {
120        match input {
121            [] => Ok(Self::default()),
122            [rssi, lqi, snr_lo, snr_hi, ..] => {
123                let snr = i16::from_le_bytes([*snr_lo, *snr_hi]);
124                Ok(Self {
125                    rssi_dbm: (*rssi != 0xFF).then(|| -i16::from(*rssi)),
126                    lqi: NonZeroU8::new(*lqi),
127                    snr_cb: (snr != i16::MIN).then_some(snr),
128                })
129            }
130            _ => Err(MetaError::Truncated),
131        }
132    }
133}
134
135/// `Recv` metadata extended with the full protocol's trailing
136/// buffered-frame fields (`RX_FLAGS`, `RX_AGE`).
137///
138/// Live deliveries may omit the trailing fields entirely (they decode
139/// as zero), keeping the encoding byte-compatible with the minimal
140/// protocol. Truncation is legal only at field boundaries.
141#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
142pub struct BufferedRxMeta {
143    pub rx: RxMeta,
144    /// Combination of the `RX_FLAG_*` bits.
145    pub flags: u8,
146    /// Seconds between reception and delivery; zero for live delivery.
147    pub age_s: u32,
148}
149
150impl BufferedRxMeta {
151    pub const WIRE_LEN: usize = RxMeta::WIRE_LEN + 5;
152
153    pub fn encode(self, out: &mut [u8]) -> Result<usize, MetaError> {
154        if out.len() < Self::WIRE_LEN {
155            return Err(MetaError::BufferTooSmall);
156        }
157        self.rx.encode(out)?;
158        out[RxMeta::WIRE_LEN] = self.flags;
159        out[RxMeta::WIRE_LEN + 1..Self::WIRE_LEN].copy_from_slice(&self.age_s.to_le_bytes());
160        Ok(Self::WIRE_LEN)
161    }
162
163    pub fn decode(input: &[u8]) -> Result<Self, MetaError> {
164        let rx = RxMeta::decode(input)?;
165        let trailer = input.get(RxMeta::WIRE_LEN..).unwrap_or(&[]);
166        let (flags, age_s) = match trailer {
167            [] => (0, 0),
168            [flags] => (*flags, 0),
169            [flags, age @ ..] if age.len() >= 4 => (
170                *flags,
171                u32::from_le_bytes(age[..4].try_into().expect("length checked")),
172            ),
173            _ => return Err(MetaError::Truncated),
174        };
175        Ok(Self { rx, flags, age_s })
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn tx_round_trip() {
185        let meta = TxMeta {
186            power: 22,
187            flags: TX_FLAG_NODUTY,
188        };
189        let mut buf = [0u8; TxMeta::WIRE_LEN];
190        assert_eq!(meta.encode(&mut buf).unwrap(), TxMeta::WIRE_LEN);
191        assert_eq!(buf, [22, 0x02]);
192        assert_eq!(TxMeta::decode(&buf).unwrap(), meta);
193    }
194
195    #[test]
196    fn tx_absent_is_default() {
197        assert_eq!(TxMeta::decode(&[]).unwrap(), TxMeta::default());
198        assert_eq!(TxMeta::default().power, TX_POWER_DEFAULT);
199        assert_eq!(TxMeta::decode(&[0x00]), Err(MetaError::Truncated));
200    }
201
202    #[test]
203    fn rx_round_trip() {
204        let meta = RxMeta {
205            rssi_dbm: Some(-91),
206            lqi: NonZeroU8::new(200),
207            snr_cb: Some(-53),
208        };
209        let mut buf = [0u8; RxMeta::WIRE_LEN];
210        meta.encode(&mut buf).unwrap();
211        // Spec example: RSSI -91 encodes as 91.
212        assert_eq!(buf[0], 91);
213        assert_eq!(RxMeta::decode(&buf).unwrap(), meta);
214    }
215
216    #[test]
217    fn rx_sentinels() {
218        let mut buf = [0u8; RxMeta::WIRE_LEN];
219        RxMeta::default().encode(&mut buf).unwrap();
220        // SNR sentinel is i16::MIN (0x8000), little-endian.
221        assert_eq!(buf, [0xFF, 0x00, 0x00, 0x80]);
222        assert_eq!(RxMeta::decode(&buf).unwrap(), RxMeta::default());
223        assert_eq!(RxMeta::decode(&[]).unwrap(), RxMeta::default());
224        assert_eq!(RxMeta::decode(&[91, 0, 0]), Err(MetaError::Truncated));
225    }
226
227    #[test]
228    fn buffered_round_trip_and_boundary_truncation() {
229        let meta = BufferedRxMeta {
230            rx: RxMeta {
231                rssi_dbm: Some(-101),
232                lqi: NonZeroU8::new(17),
233                snr_cb: Some(-22),
234            },
235            flags: RX_FLAG_BUFFERED | RX_FLAG_ACKED,
236            age_s: 3_601,
237        };
238        let mut buf = [0u8; BufferedRxMeta::WIRE_LEN];
239        assert_eq!(meta.encode(&mut buf).unwrap(), BufferedRxMeta::WIRE_LEN);
240        assert_eq!(BufferedRxMeta::decode(&buf).unwrap(), meta);
241
242        // Truncation at each legal field boundary: absent fields are zero.
243        assert_eq!(
244            BufferedRxMeta::decode(&[]).unwrap(),
245            BufferedRxMeta::default()
246        );
247        let base_only = BufferedRxMeta::decode(&buf[..RxMeta::WIRE_LEN]).unwrap();
248        assert_eq!(base_only.rx, meta.rx);
249        assert_eq!((base_only.flags, base_only.age_s), (0, 0));
250        let with_flags = BufferedRxMeta::decode(&buf[..RxMeta::WIRE_LEN + 1]).unwrap();
251        assert_eq!(with_flags.flags, meta.flags);
252        assert_eq!(with_flags.age_s, 0);
253
254        // Truncation mid-RX_AGE is malformed.
255        for len in RxMeta::WIRE_LEN + 2..BufferedRxMeta::WIRE_LEN {
256            assert_eq!(
257                BufferedRxMeta::decode(&buf[..len]),
258                Err(MetaError::Truncated)
259            );
260        }
261    }
262
263    #[test]
264    fn buffered_decode_matches_minimal_live_encoding() {
265        // A live minimal-protocol RxMeta decodes as a BufferedRxMeta with
266        // zero flags and age: the encodings stay byte-compatible.
267        let rx = RxMeta {
268            rssi_dbm: Some(-91),
269            lqi: None,
270            snr_cb: Some(55),
271        };
272        let mut buf = [0u8; RxMeta::WIRE_LEN];
273        rx.encode(&mut buf).unwrap();
274        let buffered = BufferedRxMeta::decode(&buf).unwrap();
275        assert_eq!(
276            buffered,
277            BufferedRxMeta {
278                rx,
279                flags: 0,
280                age_s: 0
281            }
282        );
283    }
284
285    #[test]
286    fn rx_snr_negative_one_round_trips() {
287        // -0.1 dB used to collide with the old 0xFFFF sentinel; with the
288        // i16::MIN sentinel it survives a round trip unchanged.
289        let meta = RxMeta {
290            rssi_dbm: Some(0),
291            lqi: None,
292            snr_cb: Some(-1),
293        };
294        let mut buf = [0u8; RxMeta::WIRE_LEN];
295        meta.encode(&mut buf).unwrap();
296        assert_eq!(RxMeta::decode(&buf).unwrap().snr_cb, Some(-1));
297    }
298}