1use alloc::format;
5use alloc::string::{String, ToString};
6use alloc::vec::Vec;
7
8use crate::txt::{TxtError, TxtRecord, check_txt};
9use crate::{
10 BrokerType, DEVICE_INFO_SERVICE, EBUS_SERVICE, EBUS_VERSION, HTTP_SERVICE, HTTPS_SERVICE,
11 ROLE_BROKER_HOST, ROLE_CONTROLLER, ROLE_DEVICE, TXTVERS, strip_dot,
12};
13
14pub const MAX_RENAME_SUFFIX: u32 = 99;
16pub const MAX_DEFAULT_INSTANCE_OCTETS: usize = 60;
19
20const HOMIE_VERSION: &str = "5";
22
23const MQTT_PROTOCOLS: [&str; 2] = ["mqtt-v5", "mqtt-v3.1.1"];
25
26fn put(txt: &mut TxtRecord, key: &str, value: Option<&str>) {
28 if let Some(v) = value.filter(|v| !v.is_empty()) {
29 txt.insert(key, v);
30 }
31}
32
33fn add_extras(txt: &mut TxtRecord, extras: &[(String, String)]) {
34 for (k, v) in extras {
35 txt.insert(k, v);
36 }
37}
38
39#[derive(Clone, Debug, PartialEq, Eq)]
46#[non_exhaustive]
47pub struct Identity {
48 pub device_ids: Vec<String>,
50 pub roles: Vec<String>,
52 pub manufacturer: String,
54 pub model: String,
56 pub serial_number: String,
58 pub ebus_version: String,
60 pub device_type: Option<String>,
62 pub name: Option<String>,
64 pub fw_version: Option<String>,
66 pub register: Option<String>,
68 pub broker_ca: Option<String>,
70 pub auth_methods: Vec<String>,
72 pub hw_version: Option<String>,
74 pub os_version: Option<String>,
76 pub mac: Option<String>,
78 pub extra_ebus_txt: Vec<(String, String)>,
80 pub extra_device_info_txt: Vec<(String, String)>,
82 pub homie_domain: Option<String>,
85 pub homie_version: Option<String>,
87}
88
89impl Identity {
90 pub fn new<D, R>(
92 device_ids: impl IntoIterator<Item = D>,
93 roles: impl IntoIterator<Item = R>,
94 manufacturer: &str,
95 model: &str,
96 serial_number: &str,
97 ) -> Self
98 where
99 D: Into<String>,
100 R: Into<String>,
101 {
102 Self {
103 device_ids: device_ids.into_iter().map(Into::into).collect(),
104 roles: roles.into_iter().map(Into::into).collect(),
105 manufacturer: manufacturer.into(),
106 model: model.into(),
107 serial_number: serial_number.into(),
108 ebus_version: EBUS_VERSION.into(),
109 device_type: None,
110 name: None,
111 fw_version: None,
112 register: None,
113 broker_ca: None,
114 auth_methods: Vec::new(),
115 hw_version: None,
116 os_version: None,
117 mac: None,
118 extra_ebus_txt: Vec::new(),
119 extra_device_info_txt: Vec::new(),
120 homie_domain: None,
121 homie_version: None,
122 }
123 }
124
125 fn trimmed(list: &[String]) -> impl Iterator<Item = &str> {
126 list.iter().map(|s| s.trim())
127 }
128
129 pub fn device_id(&self) -> String {
131 Self::trimmed(&self.device_ids)
132 .collect::<Vec<_>>()
133 .join(",")
134 }
135
136 pub fn check(&self) -> Result<(), TxtError> {
138 if self.device_ids.is_empty() {
139 return Err(TxtError::NoDeviceId);
140 }
141 if let Some(bad) = Self::trimmed(&self.device_ids).find(|d| d.is_empty() || d.contains(','))
142 {
143 return Err(TxtError::InvalidDeviceId(bad.to_string()));
144 }
145 if self.roles.is_empty() {
146 return Err(TxtError::NoRole);
147 }
148 if let Some(bad) = Self::trimmed(&self.roles).find(|r| r.is_empty() || r.contains(',')) {
149 return Err(TxtError::InvalidRole(bad.to_string()));
150 }
151 if let Some(d) = &self.homie_domain {
152 if d.contains(['/', '+', '#']) {
153 return Err(TxtError::InvalidHomieDomain(d.clone()));
154 }
155 }
156 for (field, value) in [
157 ("manufacturer", &self.manufacturer),
158 ("model", &self.model),
159 ("serial_number", &self.serial_number),
160 ("ebus_version", &self.ebus_version),
161 ] {
162 if value.is_empty() {
163 return Err(TxtError::Missing(field));
164 }
165 }
166 check_txt(EBUS_SERVICE, &self.ebus_txt())?;
167 check_txt(DEVICE_INFO_SERVICE, &self.device_info_txt())?;
168 Ok(())
169 }
170
171 pub fn ebus_txt(&self) -> TxtRecord {
173 let mut txt = TxtRecord::new();
174 txt.insert("txtvers", TXTVERS);
175 txt.insert("ebus_version", &self.ebus_version);
176 let roles: Vec<&str> = Self::trimmed(&self.roles).collect();
177 txt.insert("roles", &roles.join(","));
178 txt.insert("device_id", &self.device_id());
179 put(&mut txt, "device_type", self.device_type.as_deref());
180 put(&mut txt, "name", self.name.as_deref());
181 put(&mut txt, "manufacturer", Some(&self.manufacturer));
182 put(&mut txt, "model", Some(&self.model));
183 put(&mut txt, "fw_version", self.fw_version.as_deref());
184 put(&mut txt, "register", self.register.as_deref());
185 put(&mut txt, "broker_ca", self.broker_ca.as_deref());
186 let auth: Vec<&str> = Self::trimmed(&self.auth_methods).collect();
187 put(&mut txt, "auth_methods", Some(&auth.join(",")));
188 if let Some(domain) = self.homie_domain.as_deref().filter(|d| !d.is_empty()) {
189 txt.insert("homie_domain", domain);
190 let version = self.homie_version.as_deref().filter(|v| !v.is_empty());
191 txt.insert("homie_version", version.unwrap_or(HOMIE_VERSION));
192 let homie: Vec<&str> = roles
193 .iter()
194 .copied()
195 .filter(|r| *r == ROLE_DEVICE || *r == ROLE_CONTROLLER)
196 .collect();
197 put(&mut txt, "homie_roles", Some(&homie.join(",")));
198 }
199 add_extras(&mut txt, &self.extra_ebus_txt);
200 txt
201 }
202
203 pub fn device_info_txt(&self) -> TxtRecord {
205 let mut txt = TxtRecord::new();
206 txt.insert("txtvers", TXTVERS);
207 txt.insert("manufacturer", &self.manufacturer);
208 txt.insert("model", &self.model);
209 txt.insert("serial_number", &self.serial_number);
210 put(&mut txt, "fw_version", self.fw_version.as_deref());
211 put(&mut txt, "hw_version", self.hw_version.as_deref());
212 put(&mut txt, "os_version", self.os_version.as_deref());
213 put(&mut txt, "mac", self.mac.as_deref());
214 add_extras(&mut txt, &self.extra_device_info_txt);
215 txt
216 }
217}
218
219#[derive(Clone, Debug, PartialEq, Eq)]
221#[non_exhaustive]
222pub struct HttpService {
223 pub tls: bool,
225 pub port: u16,
227 pub path: String,
229 pub version: String,
231 pub openapi: Option<String>,
233 pub extra_txt: Vec<(String, String)>,
235}
236
237impl HttpService {
238 pub fn http(port: u16, path: &str) -> Self {
240 Self {
241 tls: false,
242 port,
243 path: path.into(),
244 version: "1.0".into(),
245 openapi: None,
246 extra_txt: Vec::new(),
247 }
248 }
249
250 pub fn https(port: u16, path: &str) -> Self {
252 Self {
253 tls: true,
254 ..Self::http(port, path)
255 }
256 }
257
258 pub fn service_type(&self) -> &'static str {
260 if self.tls {
261 HTTPS_SERVICE
262 } else {
263 HTTP_SERVICE
264 }
265 }
266
267 pub fn txt(&self, identity: &Identity) -> TxtRecord {
269 let mut txt = TxtRecord::new();
270 txt.insert("txtvers", TXTVERS);
271 txt.insert("path", &self.path);
272 txt.insert("version", &self.version);
273 txt.insert("device_id", &identity.device_id());
274 put(&mut txt, "device_type", identity.device_type.as_deref());
275 put(&mut txt, "openapi", self.openapi.as_deref());
276 add_extras(&mut txt, &self.extra_txt);
277 txt
278 }
279}
280
281#[derive(Clone, Debug, PartialEq, Eq)]
283#[non_exhaustive]
284pub struct BrokerService {
285 pub broker_type: BrokerType,
287 pub port: u16,
289 pub protocol: String,
292 pub broker: Option<String>,
295 pub path: String,
297 pub subprotocol: String,
299 pub extra_txt: Vec<(String, String)>,
301}
302
303impl BrokerService {
304 pub fn new(broker_type: BrokerType, port: u16) -> Self {
306 Self {
307 broker_type,
308 port,
309 protocol: "mqtt-v5".into(),
310 broker: None,
311 path: "/mqtt".into(),
312 subprotocol: "mqtt".into(),
313 extra_txt: Vec::new(),
314 }
315 }
316
317 pub fn txt(&self, identity: &Identity, server: &str) -> TxtRecord {
321 let mut txt = TxtRecord::new();
322 txt.insert("txtvers", TXTVERS);
323 txt.insert("protocol", &self.protocol);
324 match self.broker_type {
325 BrokerType::SecureMqtt => {
326 let broker = self.broker.as_deref().filter(|b| !b.is_empty());
327 txt.insert("broker", strip_dot(broker.unwrap_or(server)));
328 txt.insert("device_id", &identity.device_id());
329 }
330 BrokerType::MqttWss | BrokerType::MqttWs => {
331 txt.insert("path", &self.path);
332 txt.insert("subprotocol", &self.subprotocol);
333 }
334 BrokerType::Mqtt => {}
335 }
336 add_extras(&mut txt, &self.extra_txt);
337 txt
338 }
339}
340
341#[derive(Clone, Debug, PartialEq, Eq)]
343#[non_exhaustive]
344pub struct ServiceRecord {
345 pub service_type: &'static str,
347 pub port: u16,
349 pub txt: TxtRecord,
351}
352
353#[derive(Clone, Debug, PartialEq, Eq)]
355#[non_exhaustive]
356pub struct Advertisement {
357 pub identity: Identity,
359 pub instance: Option<String>,
361 pub ebus_port: Option<u16>,
363 pub device_info_port: u16,
365 pub http: Vec<HttpService>,
367 pub brokers: Vec<BrokerService>,
369}
370
371impl Advertisement {
372 pub fn new(identity: Identity) -> Self {
374 Self {
375 identity,
376 instance: None,
377 ebus_port: None,
378 device_info_port: 0,
379 http: Vec::new(),
380 brokers: Vec::new(),
381 }
382 }
383
384 pub fn instance(mut self, name: &str) -> Self {
386 self.instance = Some(name.into());
387 self
388 }
389
390 pub fn http(mut self, http: HttpService) -> Self {
392 self.http.push(http);
393 self
394 }
395
396 pub fn broker(mut self, broker: BrokerService) -> Self {
398 self.brokers.push(broker);
399 self
400 }
401
402 pub fn records(&self, server: &str) -> Result<Vec<ServiceRecord>, TxtError> {
408 let id = &self.identity;
409 id.check()?;
410 for role in &id.roles {
411 let role = role.trim();
412 if ![ROLE_DEVICE, ROLE_CONTROLLER, ROLE_BROKER_HOST].contains(&role) {
413 crate::log::warn!("unknownEbusRole", role = role);
414 }
415 }
416 for (i, b) in self.brokers.iter().enumerate() {
417 if self.brokers[..i]
418 .iter()
419 .any(|o| o.broker_type == b.broker_type)
420 {
421 return Err(TxtError::DuplicateBrokerType(b.broker_type));
422 }
423 if !MQTT_PROTOCOLS.contains(&b.protocol.as_str()) {
424 crate::log::warn!("unknownMqttProtocol", protocol = b.protocol);
425 }
426 }
427 if !self.brokers.is_empty() && !id.roles.iter().any(|r| r.trim() == ROLE_BROKER_HOST) {
428 crate::log::warn!("brokersWithoutBrokerHostRole", roles = id.roles.join(","));
429 }
430 let ebus_port = self
431 .ebus_port
432 .or_else(|| self.http.first().map(|h| h.port))
433 .unwrap_or(0);
434 let mut out = Vec::with_capacity(2 + self.http.len() + self.brokers.len());
435 out.push(ServiceRecord {
436 service_type: EBUS_SERVICE,
437 port: ebus_port,
438 txt: id.ebus_txt(),
439 });
440 out.push(ServiceRecord {
441 service_type: DEVICE_INFO_SERVICE,
442 port: self.device_info_port,
443 txt: id.device_info_txt(),
444 });
445 for h in &self.http {
446 out.push(ServiceRecord {
447 service_type: h.service_type(),
448 port: h.port,
449 txt: h.txt(id),
450 });
451 }
452 for b in &self.brokers {
453 out.push(ServiceRecord {
454 service_type: b.broker_type.as_str(),
455 port: b.port,
456 txt: b.txt(id, server),
457 });
458 }
459 for r in &out[2..] {
460 check_txt(r.service_type, &r.txt)?;
461 }
462 Ok(out)
463 }
464
465 pub fn base_instance_name(&self, server: &str) -> String {
470 if let Some(name) = self.instance.as_deref().filter(|n| !n.is_empty()) {
471 return name.into();
472 }
473 let first = self.identity.device_ids.first().map_or("", |d| d.trim());
474 if first.len() <= MAX_DEFAULT_INSTANCE_OCTETS {
475 return first.into();
476 }
477 let host = strip_dot(server);
478 let host = host.rfind(".local").map_or(host, |at| &host[..at]);
479 crate::log::info!(
480 "deviceIdTooLongForInstanceName",
481 deviceId = first,
482 instance = host
483 );
484 host.into()
485 }
486
487 pub fn instance_names(&self, server: &str) -> impl Iterator<Item = String> + use<> {
491 let base = self.base_instance_name(server);
492 core::iter::once(base.clone())
493 .chain((2..=MAX_RENAME_SUFFIX).map(move |n| format!("{base}-{n}")))
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 fn identity() -> Identity {
502 Identity::new(
503 ["panel-1", "meter-2"],
504 ["device", "broker-host"],
505 "Acme",
506 "P1",
507 "SN1",
508 )
509 }
510
511 fn pairs(txt: &TxtRecord) -> Vec<(String, String)> {
512 txt.iter()
513 .map(|(k, _)| (k.to_string(), txt.get(k).unwrap().into_owned()))
514 .collect()
515 }
516
517 fn p(list: &[(&str, &str)]) -> Vec<(String, String)> {
518 list.iter()
519 .map(|(k, v)| (k.to_string(), v.to_string()))
520 .collect()
521 }
522
523 #[test]
524 fn ebus_record() {
525 let mut id = identity();
526 id.auth_methods = alloc::vec!["passphrase".into(), " mtls ".into()];
527 id.homie_domain = Some("ebus".into());
528 id.extra_ebus_txt = p(&[("roles", "x"), ("custom", "1")]);
529 assert_eq!(
530 pairs(&id.ebus_txt()),
531 p(&[
532 ("txtvers", "1"),
533 ("ebus_version", EBUS_VERSION),
534 ("roles", "device,broker-host"),
535 ("device_id", "panel-1,meter-2"),
536 ("manufacturer", "Acme"),
537 ("model", "P1"),
538 ("auth_methods", "passphrase,mtls"),
539 ("homie_domain", "ebus"),
540 ("homie_version", "5"),
541 ("homie_roles", "device"),
542 ("custom", "1"),
543 ])
544 );
545 assert_eq!(
546 pairs(&id.device_info_txt()),
547 p(&[
548 ("txtvers", "1"),
549 ("manufacturer", "Acme"),
550 ("model", "P1"),
551 ("serial_number", "SN1")
552 ])
553 );
554 }
555
556 #[test]
557 fn identity_checks() {
558 let mut id = identity();
559 id.device_ids.clear();
560 assert_eq!(id.check(), Err(TxtError::NoDeviceId));
561 let mut id = identity();
562 id.device_ids.push("a,b".into());
563 assert_eq!(id.check(), Err(TxtError::InvalidDeviceId("a,b".into())));
564 let mut id = identity();
565 id.roles = alloc::vec![" ".into()];
566 assert_eq!(id.check(), Err(TxtError::InvalidRole("".into())));
567 let mut id = identity();
568 id.model.clear();
569 assert_eq!(id.check(), Err(TxtError::Missing("model")));
570 let mut id = identity();
571 id.homie_domain = Some("a/b".into());
572 assert!(matches!(id.check(), Err(TxtError::InvalidHomieDomain(_))));
573 let mut id = identity();
574 id.name = Some("n".repeat(260));
575 assert!(matches!(id.check(), Err(TxtError::StringTooLong { .. })));
576 }
577
578 #[test]
579 fn broker_records() {
580 let id = identity();
581 let secure = BrokerService::new(BrokerType::SecureMqtt, 8883);
582 assert_eq!(
583 pairs(&secure.txt(&id, "host.local.")),
584 p(&[
585 ("txtvers", "1"),
586 ("protocol", "mqtt-v5"),
587 ("broker", "host.local"),
588 ("device_id", "panel-1,meter-2")
589 ])
590 );
591 let mut ws = BrokerService::new(BrokerType::MqttWs, 9001);
592 ws.protocol = "mqtt-v3.1.1".into();
593 assert_eq!(
594 pairs(&ws.txt(&id, "host.local.")),
595 p(&[
596 ("txtvers", "1"),
597 ("protocol", "mqtt-v3.1.1"),
598 ("path", "/mqtt"),
599 ("subprotocol", "mqtt")
600 ])
601 );
602 let plain = BrokerService::new(BrokerType::Mqtt, 1883);
603 assert_eq!(
604 pairs(&plain.txt(&id, "h.local.")),
605 p(&[("txtvers", "1"), ("protocol", "mqtt-v5")])
606 );
607 }
608
609 #[test]
610 fn records_order_and_ports() {
611 let ad = Advertisement::new(identity())
612 .http(HttpService::https(443, "/api/v1"))
613 .broker(BrokerService::new(BrokerType::SecureMqtt, 8883))
614 .broker(BrokerService::new(BrokerType::Mqtt, 1883));
615 let r = ad.records("host.local.").unwrap();
616 let types: Vec<(&str, u16)> = r.iter().map(|r| (r.service_type, r.port)).collect();
617 assert_eq!(
618 types,
619 [
620 ("_ebus._tcp", 443),
621 ("_device-info._tcp", 0),
622 ("_https._tcp", 443),
623 ("_secure-mqtt._tcp", 8883),
624 ("_mqtt._tcp", 1883)
625 ]
626 );
627 let dup = ad
628 .clone()
629 .broker(BrokerService::new(BrokerType::Mqtt, 1884));
630 assert_eq!(
631 dup.records("h."),
632 Err(TxtError::DuplicateBrokerType(BrokerType::Mqtt))
633 );
634 }
635
636 #[test]
637 fn brokers_without_broker_host_role_keep_roles() {
638 let id = Identity::new(["d"], ["device"], "A", "M", "S");
639 let ad = Advertisement::new(id).broker(BrokerService::new(BrokerType::Mqtt, 1883));
640 let r = ad.records("h.local.").unwrap();
641 assert_eq!(r[0].txt.get("roles").as_deref(), Some("device"));
642 }
643
644 #[test]
645 fn instance_names() {
646 let ad = Advertisement::new(identity());
647 let names: Vec<String> = ad.instance_names("host.local.").collect();
648 assert_eq!(names.len(), 99);
649 assert_eq!(names[0], "panel-1");
650 assert_eq!(names[1], "panel-1-2");
651 assert_eq!(names[98], "panel-1-99");
652 assert_eq!(ad.clone().instance("Main").base_instance_name("h."), "Main");
653 let long = "d".repeat(61);
654 let ad = Advertisement::new(Identity::new([long.as_str()], ["device"], "A", "M", "S"));
655 assert_eq!(ad.base_instance_name("box.local."), "box");
656 assert_eq!(ad.base_instance_name("box.example.com"), "box.example.com");
657 let fits = "d".repeat(60);
658 let ad = Advertisement::new(Identity::new([fits.as_str()], ["device"], "A", "M", "S"));
659 assert_eq!(ad.base_instance_name("box.local."), fits);
660 }
661}