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