use std::fmt;
pub const MAX_ADVERTISING_PAYLOAD: usize = 31;
pub const APPLE_COMPANY_ID: u16 = 0x004C;
pub const EDDYSTONE_SERVICE_UUID16: u16 = 0xFEAA;
pub const EDDYSTONE_FRAME_UID: u8 = 0x00;
pub const EDDYSTONE_FRAME_URL: u8 = 0x10;
pub const IBEACON_SUBTYPE: u8 = 0x02;
pub const IBEACON_SUBTYPE_LENGTH: u8 = 0x15;
const AD_TYPE_FLAGS: u8 = 0x01;
const AD_TYPE_COMPLETE_16BIT_UUIDS: u8 = 0x03;
const AD_TYPE_SHORT_LOCAL_NAME: u8 = 0x08;
const AD_TYPE_COMPLETE_LOCAL_NAME: u8 = 0x09;
const AD_TYPE_MANUFACTURER_DATA: u8 = 0xFF;
const AD_TYPE_SERVICE_DATA_16: u8 = 0x16;
const AD_FLAGS_LE_GENERAL_DISCOVERABLE_NO_BREDR: u8 = 0x06;
pub const EDDYSTONE_URL_MAX_ENCODED: usize = 17;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PayloadError {
InvalidUuid(String),
InvalidHexId {
field: &'static str,
expected_bytes: usize,
value: String,
},
OutOfRange {
field: &'static str,
allowed: &'static str,
value: i64,
},
UnsupportedUrlScheme(String),
UrlTooLong {
encoded_len: usize,
},
UrlUnencodableCharacter(char),
}
impl fmt::Display for PayloadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PayloadError::InvalidUuid(v) => write!(
f,
"invalid UUID {v:?}: expected a 128-bit UUID such as \
\"e2c56db5-dffb-48d2-b060-d0f5a71096e0\""
),
PayloadError::InvalidHexId {
field,
expected_bytes,
value,
} => write!(
f,
"invalid {field} {value:?}: expected {expected_bytes} bytes written as \
{} hex digits",
expected_bytes * 2
),
PayloadError::OutOfRange {
field,
allowed,
value,
} => write!(f, "{field} {value} is out of range ({allowed})"),
PayloadError::UnsupportedUrlScheme(url) => write!(
f,
"URL {url:?} cannot be encoded: Eddystone-URL supports only \
http://www., https://www., http:// and https://"
),
PayloadError::UrlTooLong { encoded_len } => write!(
f,
"URL encodes to {encoded_len} octets but an Eddystone-URL frame holds at most \
{EDDYSTONE_URL_MAX_ENCODED}; shorten it or use a redirector"
),
PayloadError::UrlUnencodableCharacter(c) => write!(
f,
"URL contains {c:?}, which Eddystone-URL cannot encode (only printable ASCII \
0x21-0x7F is representable)"
),
}
}
}
impl std::error::Error for PayloadError {}
const URL_SCHEME_PREFIXES: [(&str, u8); 4] = [
("http://www.", 0x00),
("https://www.", 0x01),
("http://", 0x02),
("https://", 0x03),
];
const URL_SUFFIXES: [(&str, u8); 14] = [
(".com/", 0x00),
(".org/", 0x01),
(".edu/", 0x02),
(".net/", 0x03),
(".info/", 0x04),
(".biz/", 0x05),
(".gov/", 0x06),
(".com", 0x07),
(".org", 0x08),
(".edu", 0x09),
(".net", 0x0A),
(".info", 0x0B),
(".biz", 0x0C),
(".gov", 0x0D),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BeaconFrame {
IBeacon {
uuid: [u8; 16],
major: u16,
minor: u16,
measured_power: i8,
},
EddystoneUid {
namespace: [u8; 10],
instance: [u8; 6],
tx_power: i8,
},
EddystoneUrl {
url: String,
scheme: u8,
encoded: Vec<u8>,
tx_power: i8,
},
}
impl BeaconFrame {
pub fn ibeacon(
uuid: &str,
major: i64,
minor: i64,
measured_power: i64,
) -> Result<Self, PayloadError> {
Ok(BeaconFrame::IBeacon {
uuid: parse_uuid128(uuid)?,
major: parse_u16("major", major)?,
minor: parse_u16("minor", minor)?,
measured_power: parse_dbm("measured_power", measured_power)?,
})
}
pub fn eddystone_uid(
namespace: &str,
instance: &str,
tx_power: i64,
) -> Result<Self, PayloadError> {
Ok(BeaconFrame::EddystoneUid {
namespace: parse_namespace(namespace)?,
instance: parse_hex_id::<6>("instance", instance)?,
tx_power: parse_dbm("tx_power", tx_power)?,
})
}
pub fn eddystone_url(url: &str, tx_power: i64) -> Result<Self, PayloadError> {
let (scheme, encoded) = encode_eddystone_url(url)?;
Ok(BeaconFrame::EddystoneUrl {
url: url.to_string(),
scheme,
encoded,
tx_power: parse_dbm("tx_power", tx_power)?,
})
}
pub fn kind(&self) -> &'static str {
match self {
BeaconFrame::IBeacon { .. } => "iBeacon",
BeaconFrame::EddystoneUid { .. } => "Eddystone-UID",
BeaconFrame::EddystoneUrl { .. } => "Eddystone-URL",
}
}
pub fn manufacturer_data(&self) -> Option<(u16, Vec<u8>)> {
match self {
BeaconFrame::IBeacon {
uuid,
major,
minor,
measured_power,
} => Some((
APPLE_COMPANY_ID,
ibeacon_manufacturer_data(uuid, *major, *minor, *measured_power),
)),
_ => None,
}
}
pub fn service_data(&self) -> Option<(u16, Vec<u8>)> {
match self {
BeaconFrame::IBeacon { .. } => None,
BeaconFrame::EddystoneUid {
namespace,
instance,
tx_power,
} => Some((
EDDYSTONE_SERVICE_UUID16,
eddystone_uid_service_data(namespace, instance, *tx_power),
)),
BeaconFrame::EddystoneUrl {
scheme,
encoded,
tx_power,
..
} => Some((
EDDYSTONE_SERVICE_UUID16,
eddystone_url_service_data(*scheme, encoded, *tx_power),
)),
}
}
pub fn service_uuids16(&self) -> Vec<u16> {
match self {
BeaconFrame::IBeacon { .. } => Vec::new(),
_ => vec![EDDYSTONE_SERVICE_UUID16],
}
}
pub fn advertising_data(&self) -> Vec<u8> {
let mut ad = vec![
0x02,
AD_TYPE_FLAGS,
AD_FLAGS_LE_GENERAL_DISCOVERABLE_NO_BREDR,
];
for uuid16 in self.service_uuids16() {
ad.push(0x03);
ad.push(AD_TYPE_COMPLETE_16BIT_UUIDS);
ad.extend_from_slice(&uuid16.to_le_bytes());
}
if let Some((company, data)) = self.manufacturer_data() {
ad.push((1 + 2 + data.len()) as u8);
ad.push(AD_TYPE_MANUFACTURER_DATA);
ad.extend_from_slice(&company.to_le_bytes());
ad.extend_from_slice(&data);
}
if let Some((uuid16, data)) = self.service_data() {
ad.push((1 + 2 + data.len()) as u8);
ad.push(AD_TYPE_SERVICE_DATA_16);
ad.extend_from_slice(&uuid16.to_le_bytes());
ad.extend_from_slice(&data);
}
ad
}
pub fn local_name_budget(&self) -> usize {
MAX_ADVERTISING_PAYLOAD
.saturating_sub(self.advertising_data().len())
.saturating_sub(2)
}
pub fn fit_local_name<'a>(&self, name: &'a str) -> Option<&'a str> {
let budget = self.local_name_budget();
if budget == 0 || name.is_empty() {
return None;
}
let fitted = crate::utils::truncate::truncate_str(name, budget);
if fitted.is_empty() {
None
} else {
Some(fitted)
}
}
pub fn advertising_data_with_name(&self, name: &str) -> Vec<u8> {
let mut ad = self.advertising_data();
if let Some(fitted) = self.fit_local_name(name) {
let complete = fitted.len() == name.len();
ad.push((1 + fitted.len()) as u8);
ad.push(if complete {
AD_TYPE_COMPLETE_LOCAL_NAME
} else {
AD_TYPE_SHORT_LOCAL_NAME
});
ad.extend_from_slice(fitted.as_bytes());
}
ad
}
pub fn describe(&self) -> String {
match self {
BeaconFrame::IBeacon {
uuid,
major,
minor,
measured_power,
} => format!(
"iBeacon uuid={} major={} minor={} measured_power={}dBm",
format_uuid(uuid),
major,
minor,
measured_power
),
BeaconFrame::EddystoneUid {
namespace,
instance,
tx_power,
} => format!(
"Eddystone-UID namespace={} instance={} tx_power={}dBm",
hex_lower(namespace),
hex_lower(instance),
tx_power
),
BeaconFrame::EddystoneUrl { url, tx_power, .. } => {
format!("Eddystone-URL url={url} tx_power={tx_power}dBm")
}
}
}
}
pub fn ibeacon_manufacturer_data(
uuid: &[u8; 16],
major: u16,
minor: u16,
measured_power: i8,
) -> Vec<u8> {
let mut data = Vec::with_capacity(23);
data.push(IBEACON_SUBTYPE);
data.push(IBEACON_SUBTYPE_LENGTH);
data.extend_from_slice(uuid);
data.extend_from_slice(&major.to_be_bytes());
data.extend_from_slice(&minor.to_be_bytes());
data.push(measured_power as u8);
data
}
pub fn eddystone_uid_service_data(
namespace: &[u8; 10],
instance: &[u8; 6],
tx_power: i8,
) -> Vec<u8> {
let mut data = Vec::with_capacity(20);
data.push(EDDYSTONE_FRAME_UID);
data.push(tx_power as u8);
data.extend_from_slice(namespace);
data.extend_from_slice(instance);
data.extend_from_slice(&[0x00, 0x00]);
data
}
pub fn eddystone_url_service_data(scheme: u8, encoded: &[u8], tx_power: i8) -> Vec<u8> {
let mut data = Vec::with_capacity(3 + encoded.len());
data.push(EDDYSTONE_FRAME_URL);
data.push(tx_power as u8);
data.push(scheme);
data.extend_from_slice(encoded);
data
}
pub fn encode_eddystone_url(url: &str) -> Result<(u8, Vec<u8>), PayloadError> {
let trimmed = url.trim();
let lowered = trimmed.to_ascii_lowercase();
let (prefix_len, scheme) = URL_SCHEME_PREFIXES
.iter()
.find(|(prefix, _)| lowered.starts_with(prefix))
.map(|(prefix, code)| (prefix.len(), *code))
.ok_or_else(|| PayloadError::UnsupportedUrlScheme(trimmed.to_string()))?;
let body = &trimmed[prefix_len..];
let mut encoded: Vec<u8> = Vec::with_capacity(body.len());
let mut rest = body;
'outer: while !rest.is_empty() {
for (text, code) in URL_SUFFIXES.iter() {
if rest
.get(..text.len())
.is_some_and(|head| head.eq_ignore_ascii_case(text))
{
encoded.push(*code);
rest = &rest[text.len()..];
continue 'outer;
}
}
let c = rest.chars().next().expect("rest is non-empty");
if !c.is_ascii() || (c as u32) < 0x21 || (c as u32) > 0x7F {
return Err(PayloadError::UrlUnencodableCharacter(c));
}
encoded.push(c as u8);
rest = &rest[c.len_utf8()..];
}
if encoded.len() > EDDYSTONE_URL_MAX_ENCODED {
return Err(PayloadError::UrlTooLong {
encoded_len: encoded.len(),
});
}
Ok((scheme, encoded))
}
pub fn parse_uuid128(s: &str) -> Result<[u8; 16], PayloadError> {
let cleaned: String = s
.trim()
.trim_start_matches("0x")
.trim_start_matches("0X")
.chars()
.filter(|c| *c != '-')
.collect();
if cleaned.len() != 32 || !cleaned.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(PayloadError::InvalidUuid(s.trim().to_string()));
}
let mut out = [0u8; 16];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16)
.map_err(|_| PayloadError::InvalidUuid(s.trim().to_string()))?;
}
Ok(out)
}
fn parse_namespace(s: &str) -> Result<[u8; 10], PayloadError> {
let cleaned: String = s
.trim()
.trim_start_matches("0x")
.chars()
.filter(|c| *c != '-')
.collect();
if cleaned.len() == 32 {
let uuid = parse_uuid128(s)?;
let mut out = [0u8; 10];
out.copy_from_slice(&uuid[..10]);
return Ok(out);
}
parse_hex_id::<10>("namespace", s)
}
fn parse_hex_id<const N: usize>(field: &'static str, s: &str) -> Result<[u8; N], PayloadError> {
let cleaned: String = s
.trim()
.trim_start_matches("0x")
.trim_start_matches("0X")
.chars()
.filter(|c| *c != '-')
.collect();
if cleaned.len() != N * 2 || !cleaned.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(PayloadError::InvalidHexId {
field,
expected_bytes: N,
value: s.trim().to_string(),
});
}
let mut out = [0u8; N];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16).map_err(|_| {
PayloadError::InvalidHexId {
field,
expected_bytes: N,
value: s.trim().to_string(),
}
})?;
}
Ok(out)
}
fn parse_u16(field: &'static str, value: i64) -> Result<u16, PayloadError> {
u16::try_from(value).map_err(|_| PayloadError::OutOfRange {
field,
allowed: "0 to 65535",
value,
})
}
fn parse_dbm(field: &'static str, value: i64) -> Result<i8, PayloadError> {
i8::try_from(value).map_err(|_| PayloadError::OutOfRange {
field,
allowed: "-128 to 127 dBm",
value,
})
}
pub fn hex_lower(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn format_uuid(uuid: &[u8; 16]) -> String {
let h = hex_lower(uuid);
format!(
"{}-{}-{}-{}-{}",
&h[0..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..32]
)
}