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}