Skip to main content

netget/server/bluetooth_ble_beacon/
payload.rs

1//! Beacon advertising payload construction.
2//!
3//! Pure byte building: no Bluetooth, no D-Bus, no I/O, no platform `cfg`. Everything here is
4//! reachable and testable on any host, which matters because this is the only part of the
5//! protocol that *can* be verified without a Linux box and a BLE scanner
6//! (`tests/server/bluetooth_ble_beacon/payload_test.rs` checks every layout below against
7//! literal spec-derived bytes).
8//!
9//! # Two views of the same beacon
10//!
11//! BlueZ does not take a finished advertising packet. `org.bluez.LEAdvertisement1` takes
12//! *fields* — `ManufacturerData` (a company id → bytes map) and `ServiceData` (a UUID → bytes
13//! map) — and composes the AD structures itself. So the bytes the transport actually needs are
14//! [`BeaconFrame::manufacturer_data`] and [`BeaconFrame::service_data`].
15//!
16//! [`BeaconFrame::advertising_data`] builds the complete AD payload as a scanner observes it
17//! (flags, service-UUID list, and the manufacturer/service-data structure with its length and
18//! type bytes). Nothing sends those bytes — they exist so the published layouts can be asserted
19//! byte-for-byte in a test, and so [`BeaconFrame::local_name_budget`] can tell whether a device
20//! name still fits in the 31-octet legacy advertising payload.
21//!
22//! # References
23//!
24//! - iBeacon: Apple, "Getting Started with iBeacon" (2014), §2.1 iBeacon advertisement.
25//! - Eddystone: <https://github.com/google/eddystone> — `eddystone-uid` and `eddystone-url`
26//!   protocol specifications.
27//! - AD types and the 31-octet limit: Bluetooth Core Specification Supplement, Part A.
28
29use std::fmt;
30
31/// Maximum length of a legacy (non-extended) BLE advertising payload, in octets.
32pub const MAX_ADVERTISING_PAYLOAD: usize = 31;
33
34/// Apple's Bluetooth SIG company identifier, used by iBeacon.
35pub const APPLE_COMPANY_ID: u16 = 0x004C;
36
37/// The 16-bit service UUID Eddystone frames are published under.
38pub const EDDYSTONE_SERVICE_UUID16: u16 = 0xFEAA;
39
40/// Eddystone frame type for the UID frame.
41pub const EDDYSTONE_FRAME_UID: u8 = 0x00;
42/// Eddystone frame type for the URL frame.
43pub const EDDYSTONE_FRAME_URL: u8 = 0x10;
44
45/// iBeacon sub-type byte inside Apple's manufacturer-specific data.
46pub const IBEACON_SUBTYPE: u8 = 0x02;
47/// iBeacon sub-type length byte: the 21 octets that follow it.
48pub const IBEACON_SUBTYPE_LENGTH: u8 = 0x15;
49
50/// AD type: flags.
51const AD_TYPE_FLAGS: u8 = 0x01;
52/// AD type: complete list of 16-bit service UUIDs.
53const AD_TYPE_COMPLETE_16BIT_UUIDS: u8 = 0x03;
54/// AD type: shortened local name.
55const AD_TYPE_SHORT_LOCAL_NAME: u8 = 0x08;
56/// AD type: complete local name.
57const AD_TYPE_COMPLETE_LOCAL_NAME: u8 = 0x09;
58/// AD type: manufacturer specific data.
59const AD_TYPE_MANUFACTURER_DATA: u8 = 0xFF;
60/// AD type: service data, 16-bit UUID.
61const AD_TYPE_SERVICE_DATA_16: u8 = 0x16;
62
63/// Flags value used by beacons: LE General Discoverable + BR/EDR not supported.
64const AD_FLAGS_LE_GENERAL_DISCOVERABLE_NO_BREDR: u8 = 0x06;
65
66/// Longest encoded Eddystone-URL body, in octets.
67///
68/// The service-data AD structure may carry at most 20 payload octets once the 31-octet budget
69/// has paid for flags (3), the 16-bit service UUID list (4), and the structure's own length,
70/// type and UUID octets (4). Frame type, TX power and URL scheme take three of those twenty.
71pub const EDDYSTONE_URL_MAX_ENCODED: usize = 17;
72
73/// Something the caller asked for that cannot be expressed as a beacon payload.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum PayloadError {
76    /// A UUID string did not parse as a 128-bit UUID.
77    InvalidUuid(String),
78    /// A hex identifier was the wrong length or contained non-hex characters.
79    InvalidHexId {
80        /// Field name, for the message the model sees.
81        field: &'static str,
82        /// Expected length in bytes.
83        expected_bytes: usize,
84        /// What was supplied.
85        value: String,
86    },
87    /// An integer field was outside the range the wire format allows.
88    OutOfRange {
89        /// Field name.
90        field: &'static str,
91        /// Description of the accepted range.
92        allowed: &'static str,
93        /// What was supplied.
94        value: i64,
95    },
96    /// A URL did not start with a scheme Eddystone can encode.
97    UnsupportedUrlScheme(String),
98    /// The URL encoded to more than [`EDDYSTONE_URL_MAX_ENCODED`] octets.
99    UrlTooLong {
100        /// Encoded length in octets.
101        encoded_len: usize,
102    },
103    /// The URL contained a character Eddystone's encoding cannot represent.
104    UrlUnencodableCharacter(char),
105}
106
107impl fmt::Display for PayloadError {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            PayloadError::InvalidUuid(v) => write!(
111                f,
112                "invalid UUID {v:?}: expected a 128-bit UUID such as \
113                 \"e2c56db5-dffb-48d2-b060-d0f5a71096e0\""
114            ),
115            PayloadError::InvalidHexId {
116                field,
117                expected_bytes,
118                value,
119            } => write!(
120                f,
121                "invalid {field} {value:?}: expected {expected_bytes} bytes written as \
122                 {} hex digits",
123                expected_bytes * 2
124            ),
125            PayloadError::OutOfRange {
126                field,
127                allowed,
128                value,
129            } => write!(f, "{field} {value} is out of range ({allowed})"),
130            PayloadError::UnsupportedUrlScheme(url) => write!(
131                f,
132                "URL {url:?} cannot be encoded: Eddystone-URL supports only \
133                 http://www., https://www., http:// and https://"
134            ),
135            PayloadError::UrlTooLong { encoded_len } => write!(
136                f,
137                "URL encodes to {encoded_len} octets but an Eddystone-URL frame holds at most \
138                 {EDDYSTONE_URL_MAX_ENCODED}; shorten it or use a redirector"
139            ),
140            PayloadError::UrlUnencodableCharacter(c) => write!(
141                f,
142                "URL contains {c:?}, which Eddystone-URL cannot encode (only printable ASCII \
143                 0x21-0x7F is representable)"
144            ),
145        }
146    }
147}
148
149impl std::error::Error for PayloadError {}
150
151/// Eddystone-URL scheme prefixes, indexed by their encoded byte.
152const URL_SCHEME_PREFIXES: [(&str, u8); 4] = [
153    ("http://www.", 0x00),
154    ("https://www.", 0x01),
155    ("http://", 0x02),
156    ("https://", 0x03),
157];
158
159/// Eddystone-URL suffix substitutions, in encoding order.
160///
161/// The spec assigns 0x00-0x0D to these strings anywhere in the URL body, not only at the end;
162/// the encoder below applies them greedily wherever they match. Omitting them is not merely
163/// wasteful — `.com/` costs five octets uncompressed out of a 17-octet budget.
164const URL_SUFFIXES: [(&str, u8); 14] = [
165    (".com/", 0x00),
166    (".org/", 0x01),
167    (".edu/", 0x02),
168    (".net/", 0x03),
169    (".info/", 0x04),
170    (".biz/", 0x05),
171    (".gov/", 0x06),
172    (".com", 0x07),
173    (".org", 0x08),
174    (".edu", 0x09),
175    (".net", 0x0A),
176    (".info", 0x0B),
177    (".biz", 0x0C),
178    (".gov", 0x0D),
179];
180
181/// A beacon advertisement, described by the fields a model can actually produce.
182///
183/// Constructed from structured action parameters (a UUID string, integers, a URL) — never from
184/// a blob of bytes. The byte layout is this module's job, not the model's.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum BeaconFrame {
187    /// Apple iBeacon: proximity UUID, major, minor, and the RSSI measured at 1 m.
188    IBeacon {
189        /// 128-bit proximity UUID.
190        uuid: [u8; 16],
191        /// Major value (big-endian on the wire).
192        major: u16,
193        /// Minor value (big-endian on the wire).
194        minor: u16,
195        /// Calibrated RSSI at 1 m, in dBm.
196        measured_power: i8,
197    },
198    /// Eddystone-UID: a 10-byte namespace and a 6-byte instance.
199    EddystoneUid {
200        /// 10-byte namespace id.
201        namespace: [u8; 10],
202        /// 6-byte instance id.
203        instance: [u8; 6],
204        /// Calibrated TX power at 0 m, in dBm.
205        tx_power: i8,
206    },
207    /// Eddystone-URL: a compressed http(s) URL.
208    EddystoneUrl {
209        /// The URL as supplied, kept for logging and equality.
210        url: String,
211        /// Scheme prefix code (0x00-0x03).
212        scheme: u8,
213        /// The encoded body, already validated to fit.
214        encoded: Vec<u8>,
215        /// Calibrated TX power at 0 m, in dBm.
216        tx_power: i8,
217    },
218}
219
220impl BeaconFrame {
221    /// Build an iBeacon frame from structured fields.
222    pub fn ibeacon(
223        uuid: &str,
224        major: i64,
225        minor: i64,
226        measured_power: i64,
227    ) -> Result<Self, PayloadError> {
228        Ok(BeaconFrame::IBeacon {
229            uuid: parse_uuid128(uuid)?,
230            major: parse_u16("major", major)?,
231            minor: parse_u16("minor", minor)?,
232            measured_power: parse_dbm("measured_power", measured_power)?,
233        })
234    }
235
236    /// Build an Eddystone-UID frame from structured fields.
237    ///
238    /// `namespace` accepts the canonical 20 hex digits, or a full 128-bit UUID whose first 10
239    /// bytes are taken (the spec's "truncated UUID" derivation). `instance` accepts 12 hex
240    /// digits. Both are identifiers in their published textual form, not opaque payload bytes —
241    /// the beacon's actual payload is built here, from these.
242    pub fn eddystone_uid(
243        namespace: &str,
244        instance: &str,
245        tx_power: i64,
246    ) -> Result<Self, PayloadError> {
247        Ok(BeaconFrame::EddystoneUid {
248            namespace: parse_namespace(namespace)?,
249            instance: parse_hex_id::<6>("instance", instance)?,
250            tx_power: parse_dbm("tx_power", tx_power)?,
251        })
252    }
253
254    /// Build an Eddystone-URL frame, failing if the URL cannot be encoded or does not fit.
255    pub fn eddystone_url(url: &str, tx_power: i64) -> Result<Self, PayloadError> {
256        let (scheme, encoded) = encode_eddystone_url(url)?;
257        Ok(BeaconFrame::EddystoneUrl {
258            url: url.to_string(),
259            scheme,
260            encoded,
261            tx_power: parse_dbm("tx_power", tx_power)?,
262        })
263    }
264
265    /// Short human-readable identity, for status lines and logs.
266    pub fn kind(&self) -> &'static str {
267        match self {
268            BeaconFrame::IBeacon { .. } => "iBeacon",
269            BeaconFrame::EddystoneUid { .. } => "Eddystone-UID",
270            BeaconFrame::EddystoneUrl { .. } => "Eddystone-URL",
271        }
272    }
273
274    /// The `ManufacturerData` entry BlueZ needs, as `(company_id, bytes)`.
275    ///
276    /// Only iBeacon uses manufacturer data; Eddystone frames return `None`.
277    pub fn manufacturer_data(&self) -> Option<(u16, Vec<u8>)> {
278        match self {
279            BeaconFrame::IBeacon {
280                uuid,
281                major,
282                minor,
283                measured_power,
284            } => Some((
285                APPLE_COMPANY_ID,
286                ibeacon_manufacturer_data(uuid, *major, *minor, *measured_power),
287            )),
288            _ => None,
289        }
290    }
291
292    /// The `ServiceData` entry BlueZ needs, as `(uuid16, bytes)`.
293    ///
294    /// Only Eddystone frames use service data; iBeacon returns `None`.
295    pub fn service_data(&self) -> Option<(u16, Vec<u8>)> {
296        match self {
297            BeaconFrame::IBeacon { .. } => None,
298            BeaconFrame::EddystoneUid {
299                namespace,
300                instance,
301                tx_power,
302            } => Some((
303                EDDYSTONE_SERVICE_UUID16,
304                eddystone_uid_service_data(namespace, instance, *tx_power),
305            )),
306            BeaconFrame::EddystoneUrl {
307                scheme,
308                encoded,
309                tx_power,
310                ..
311            } => Some((
312                EDDYSTONE_SERVICE_UUID16,
313                eddystone_url_service_data(*scheme, encoded, *tx_power),
314            )),
315        }
316    }
317
318    /// The 16-bit service UUIDs that must appear in the advertisement.
319    ///
320    /// Eddystone requires 0xFEAA in the "complete list of 16-bit service UUIDs" alongside its
321    /// service data; iBeacon advertises none.
322    pub fn service_uuids16(&self) -> Vec<u16> {
323        match self {
324            BeaconFrame::IBeacon { .. } => Vec::new(),
325            _ => vec![EDDYSTONE_SERVICE_UUID16],
326        }
327    }
328
329    /// The complete AD payload a scanner observes, excluding any local name.
330    ///
331    /// Not sent anywhere: BlueZ composes the real packet from [`Self::manufacturer_data`],
332    /// [`Self::service_data`] and [`Self::service_uuids16`]. This exists to make the published
333    /// layouts assertable and to size the local-name budget.
334    pub fn advertising_data(&self) -> Vec<u8> {
335        let mut ad = vec![
336            0x02,
337            AD_TYPE_FLAGS,
338            AD_FLAGS_LE_GENERAL_DISCOVERABLE_NO_BREDR,
339        ];
340
341        for uuid16 in self.service_uuids16() {
342            ad.push(0x03);
343            ad.push(AD_TYPE_COMPLETE_16BIT_UUIDS);
344            ad.extend_from_slice(&uuid16.to_le_bytes());
345        }
346
347        if let Some((company, data)) = self.manufacturer_data() {
348            ad.push((1 + 2 + data.len()) as u8);
349            ad.push(AD_TYPE_MANUFACTURER_DATA);
350            ad.extend_from_slice(&company.to_le_bytes());
351            ad.extend_from_slice(&data);
352        }
353
354        if let Some((uuid16, data)) = self.service_data() {
355            ad.push((1 + 2 + data.len()) as u8);
356            ad.push(AD_TYPE_SERVICE_DATA_16);
357            ad.extend_from_slice(&uuid16.to_le_bytes());
358            ad.extend_from_slice(&data);
359        }
360
361        ad
362    }
363
364    /// How many octets of device name still fit in the 31-octet payload, if any.
365    ///
366    /// A local-name AD structure costs two octets of overhead on top of the name, so a budget
367    /// below one means the name has to be dropped. iBeacon leaves exactly one spare octet and
368    /// therefore never carries a name — which is correct rather than a limitation: an iBeacon
369    /// advertisement is defined as manufacturer data and nothing else. Returning the budget
370    /// instead of silently overflowing is what keeps BlueZ from refusing to register the
371    /// advertisement at all.
372    pub fn local_name_budget(&self) -> usize {
373        MAX_ADVERTISING_PAYLOAD
374            .saturating_sub(self.advertising_data().len())
375            .saturating_sub(2)
376    }
377
378    /// The device name to advertise, truncated to fit, or `None` when there is no room.
379    ///
380    /// Truncation is on a char boundary: a byte-index cut through multi-byte UTF-8 panics, and
381    /// this name arrives from LLM or MCP input.
382    pub fn fit_local_name<'a>(&self, name: &'a str) -> Option<&'a str> {
383        let budget = self.local_name_budget();
384        if budget == 0 || name.is_empty() {
385            return None;
386        }
387        let fitted = crate::utils::truncate::truncate_str(name, budget);
388        if fitted.is_empty() {
389            None
390        } else {
391            Some(fitted)
392        }
393    }
394
395    /// The AD payload including the local name that fits, for verification and logging.
396    pub fn advertising_data_with_name(&self, name: &str) -> Vec<u8> {
397        let mut ad = self.advertising_data();
398        if let Some(fitted) = self.fit_local_name(name) {
399            let complete = fitted.len() == name.len();
400            ad.push((1 + fitted.len()) as u8);
401            ad.push(if complete {
402                AD_TYPE_COMPLETE_LOCAL_NAME
403            } else {
404                AD_TYPE_SHORT_LOCAL_NAME
405            });
406            ad.extend_from_slice(fitted.as_bytes());
407        }
408        ad
409    }
410
411    /// One-line description for status output and the access log.
412    pub fn describe(&self) -> String {
413        match self {
414            BeaconFrame::IBeacon {
415                uuid,
416                major,
417                minor,
418                measured_power,
419            } => format!(
420                "iBeacon uuid={} major={} minor={} measured_power={}dBm",
421                format_uuid(uuid),
422                major,
423                minor,
424                measured_power
425            ),
426            BeaconFrame::EddystoneUid {
427                namespace,
428                instance,
429                tx_power,
430            } => format!(
431                "Eddystone-UID namespace={} instance={} tx_power={}dBm",
432                hex_lower(namespace),
433                hex_lower(instance),
434                tx_power
435            ),
436            BeaconFrame::EddystoneUrl { url, tx_power, .. } => {
437                format!("Eddystone-URL url={url} tx_power={tx_power}dBm")
438            }
439        }
440    }
441}
442
443/// Apple's manufacturer-specific data for an iBeacon, without the AD length/type/company octets.
444///
445/// Layout (Apple, "Getting Started with iBeacon" §2.1), 23 octets:
446///
447/// ```text
448/// 02        iBeacon sub-type
449/// 15        sub-type length (21 octets follow)
450/// 16 bytes  proximity UUID, big-endian
451/// 2 bytes   major, big-endian
452/// 2 bytes   minor, big-endian
453/// 1 byte    measured power (signed dBm at 1 m)
454/// ```
455pub fn ibeacon_manufacturer_data(
456    uuid: &[u8; 16],
457    major: u16,
458    minor: u16,
459    measured_power: i8,
460) -> Vec<u8> {
461    let mut data = Vec::with_capacity(23);
462    data.push(IBEACON_SUBTYPE);
463    data.push(IBEACON_SUBTYPE_LENGTH);
464    data.extend_from_slice(uuid);
465    data.extend_from_slice(&major.to_be_bytes());
466    data.extend_from_slice(&minor.to_be_bytes());
467    data.push(measured_power as u8);
468    data
469}
470
471/// Eddystone-UID service data, without the AD length/type/UUID octets.
472///
473/// Layout (google/eddystone `eddystone-uid`), 20 octets:
474///
475/// ```text
476/// 00        frame type: UID
477/// 1 byte    ranging data (signed dBm at 0 m)
478/// 10 bytes  namespace
479/// 6 bytes   instance
480/// 00 00     RFU
481/// ```
482pub fn eddystone_uid_service_data(
483    namespace: &[u8; 10],
484    instance: &[u8; 6],
485    tx_power: i8,
486) -> Vec<u8> {
487    let mut data = Vec::with_capacity(20);
488    data.push(EDDYSTONE_FRAME_UID);
489    data.push(tx_power as u8);
490    data.extend_from_slice(namespace);
491    data.extend_from_slice(instance);
492    data.extend_from_slice(&[0x00, 0x00]);
493    data
494}
495
496/// Eddystone-URL service data, without the AD length/type/UUID octets.
497///
498/// Layout (google/eddystone `eddystone-url`), 4 to 20 octets:
499///
500/// ```text
501/// 10        frame type: URL
502/// 1 byte    TX power (signed dBm at 0 m)
503/// 1 byte    URL scheme prefix
504/// 0-17      encoded URL
505/// ```
506pub fn eddystone_url_service_data(scheme: u8, encoded: &[u8], tx_power: i8) -> Vec<u8> {
507    let mut data = Vec::with_capacity(3 + encoded.len());
508    data.push(EDDYSTONE_FRAME_URL);
509    data.push(tx_power as u8);
510    data.push(scheme);
511    data.extend_from_slice(encoded);
512    data
513}
514
515/// Encode an http(s) URL into an Eddystone scheme code and compressed body.
516///
517/// Applies both substitution tables: the four scheme prefixes, and the fourteen
518/// `.com/`-style substitutions, which the spec permits anywhere in the body rather than only
519/// at the end. Everything else must be printable ASCII (0x21-0x7F); the reserved ranges
520/// 0x0E-0x20 and 0x80-0xFF cannot be sent, so a URL containing a space or a non-ASCII
521/// character is refused rather than mangled.
522pub fn encode_eddystone_url(url: &str) -> Result<(u8, Vec<u8>), PayloadError> {
523    let trimmed = url.trim();
524    let lowered = trimmed.to_ascii_lowercase();
525
526    let (prefix_len, scheme) = URL_SCHEME_PREFIXES
527        .iter()
528        .find(|(prefix, _)| lowered.starts_with(prefix))
529        .map(|(prefix, code)| (prefix.len(), *code))
530        .ok_or_else(|| PayloadError::UnsupportedUrlScheme(trimmed.to_string()))?;
531
532    let body = &trimmed[prefix_len..];
533    let mut encoded: Vec<u8> = Vec::with_capacity(body.len());
534    let mut rest = body;
535
536    'outer: while !rest.is_empty() {
537        for (text, code) in URL_SUFFIXES.iter() {
538            // `get`, not `&rest[..n]`: a byte-index slice through a multi-byte character
539            // panics, and "aaaaä" reaches this loop with a 5-byte cut landing inside the 'ä'.
540            // A non-boundary index simply means the substitution does not match here.
541            if rest
542                .get(..text.len())
543                .is_some_and(|head| head.eq_ignore_ascii_case(text))
544            {
545                encoded.push(*code);
546                rest = &rest[text.len()..];
547                continue 'outer;
548            }
549        }
550        let c = rest.chars().next().expect("rest is non-empty");
551        if !c.is_ascii() || (c as u32) < 0x21 || (c as u32) > 0x7F {
552            return Err(PayloadError::UrlUnencodableCharacter(c));
553        }
554        encoded.push(c as u8);
555        rest = &rest[c.len_utf8()..];
556    }
557
558    if encoded.len() > EDDYSTONE_URL_MAX_ENCODED {
559        return Err(PayloadError::UrlTooLong {
560            encoded_len: encoded.len(),
561        });
562    }
563
564    Ok((scheme, encoded))
565}
566
567/// Parse a 128-bit UUID in hyphenated or bare-hex form into its 16 big-endian bytes.
568pub fn parse_uuid128(s: &str) -> Result<[u8; 16], PayloadError> {
569    let cleaned: String = s
570        .trim()
571        .trim_start_matches("0x")
572        .trim_start_matches("0X")
573        .chars()
574        .filter(|c| *c != '-')
575        .collect();
576    if cleaned.len() != 32 || !cleaned.chars().all(|c| c.is_ascii_hexdigit()) {
577        return Err(PayloadError::InvalidUuid(s.trim().to_string()));
578    }
579    let mut out = [0u8; 16];
580    for (i, byte) in out.iter_mut().enumerate() {
581        *byte = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16)
582            .map_err(|_| PayloadError::InvalidUuid(s.trim().to_string()))?;
583    }
584    Ok(out)
585}
586
587/// Parse an Eddystone namespace: 20 hex digits, or a UUID whose first 10 bytes are used.
588fn parse_namespace(s: &str) -> Result<[u8; 10], PayloadError> {
589    let cleaned: String = s
590        .trim()
591        .trim_start_matches("0x")
592        .chars()
593        .filter(|c| *c != '-')
594        .collect();
595    if cleaned.len() == 32 {
596        let uuid = parse_uuid128(s)?;
597        let mut out = [0u8; 10];
598        out.copy_from_slice(&uuid[..10]);
599        return Ok(out);
600    }
601    parse_hex_id::<10>("namespace", s)
602}
603
604/// Parse a fixed-width hex identifier, tolerating a `0x` prefix and separating hyphens.
605fn parse_hex_id<const N: usize>(field: &'static str, s: &str) -> Result<[u8; N], PayloadError> {
606    let cleaned: String = s
607        .trim()
608        .trim_start_matches("0x")
609        .trim_start_matches("0X")
610        .chars()
611        .filter(|c| *c != '-')
612        .collect();
613    if cleaned.len() != N * 2 || !cleaned.chars().all(|c| c.is_ascii_hexdigit()) {
614        return Err(PayloadError::InvalidHexId {
615            field,
616            expected_bytes: N,
617            value: s.trim().to_string(),
618        });
619    }
620    let mut out = [0u8; N];
621    for (i, byte) in out.iter_mut().enumerate() {
622        *byte = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16).map_err(|_| {
623            PayloadError::InvalidHexId {
624                field,
625                expected_bytes: N,
626                value: s.trim().to_string(),
627            }
628        })?;
629    }
630    Ok(out)
631}
632
633/// Range-check a 16-bit field supplied as JSON.
634fn parse_u16(field: &'static str, value: i64) -> Result<u16, PayloadError> {
635    u16::try_from(value).map_err(|_| PayloadError::OutOfRange {
636        field,
637        allowed: "0 to 65535",
638        value,
639    })
640}
641
642/// Range-check a signed dBm field supplied as JSON.
643fn parse_dbm(field: &'static str, value: i64) -> Result<i8, PayloadError> {
644    i8::try_from(value).map_err(|_| PayloadError::OutOfRange {
645        field,
646        allowed: "-128 to 127 dBm",
647        value,
648    })
649}
650
651/// Lowercase hex, no separators.
652pub fn hex_lower(bytes: &[u8]) -> String {
653    bytes.iter().map(|b| format!("{b:02x}")).collect()
654}
655
656/// Canonical hyphenated form of a 128-bit UUID.
657pub fn format_uuid(uuid: &[u8; 16]) -> String {
658    let h = hex_lower(uuid);
659    format!(
660        "{}-{}-{}-{}-{}",
661        &h[0..8],
662        &h[8..12],
663        &h[12..16],
664        &h[16..20],
665        &h[20..32]
666    )
667}