Skip to main content

ebus_homie/controller/
mod.rs

1//! The Homie 5 controller as a sans-IO state machine.
2//!
3//! Input arrives through the `handle_*` methods and output leaves through the
4//! `poll_*` methods; time comes only from the caller's [`Instant`]. One owner
5//! drives a [`Controller`]:
6//!
7//! 1. call [`handle_link`](Controller::handle_link) on every CONNACK and every
8//!    lost connection, including `Up { session_present: false }` when attaching
9//!    to a client that is already connected;
10//! 2. pass every received message to
11//!    [`handle_message`](Controller::handle_message) (or only those for which
12//!    [`wants`](Controller::wants) is true);
13//! 3. call [`handle_timeout`](Controller::handle_timeout) once the instant
14//!    [`poll_timeout`](Controller::poll_timeout) returns has passed;
15//! 4. after each call, hand every [`poll_command`](Controller::poll_command)
16//!    result to the MQTT client, in order, and report one it could not accept
17//!    through [`handle_command_failed`](Controller::handle_command_failed);
18//!    read events with [`poll_event`](Controller::poll_event).
19//!
20//! Events are hints; the views returned by [`Controller::device`] are the
21//! truth.
22//!
23//! The host's own subscriptions are its business: declare in
24//! [`Config::host_filters`] every filter string it also subscribes on the same
25//! client, so the controller never unsubscribes it, and leave resubscribing the
26//! controller's filters after a reconnect to the controller.
27
28mod inbound;
29mod key;
30mod pacing;
31mod replay;
32#[cfg(test)]
33mod tests;
34mod types;
35
36pub use types::{
37    Config, ConfigError, DeviceView, Discovery, Domain, Event, Handled, Id, Missing, Origin,
38    Pacing, PropertyAddr, PropertyView, SetError, SetOptions, Value,
39};
40
41use alloc::{
42    collections::{BTreeMap, BTreeSet, VecDeque},
43    format,
44    string::String,
45    sync::Arc,
46    vec::Vec,
47};
48use core::ops::Bound;
49
50use ebus_mqtt::{Command, FilterLedger, Instant, QoS};
51
52use crate::HOMIE_TOPIC_VERSION;
53use crate::datatype::{self, Current, Datatype};
54use crate::description::{Ignored, PropertyDescription};
55use crate::id::is_valid_id;
56use crate::payload::EMPTY_STRING_PAYLOAD;
57use crate::state::{DeviceState, effective_state};
58use key::{DevKey, KeyRef};
59use types::Slot;
60
61/// Retry backoff for a command the host could not hand to its client
62/// (client.py:155).
63const RETRY_MIN_MS: u64 = 1_000;
64const RETRY_MAX_MS: u64 = 30_000;
65
66/// The property filter suffixes for a whole device.
67const WHOLE_DEVICE: [&str; 2] = ["+/+", "+/+/$target"];
68
69/// The alert filter suffix a device subscribed node by node also holds.
70const ALERTS: &str = "$alert/+";
71
72/// Which nodes have property filters subscribed.
73#[derive(Clone, Debug, PartialEq, Eq)]
74enum Coverage {
75    None,
76    All,
77    Nodes(BTreeSet<Id>),
78}
79
80/// A value or `$target` received before the device's `$description`.
81#[derive(Clone, Debug, Default)]
82struct Early {
83    value: Option<(String, Option<bool>)>,
84    target: Option<(String, Option<bool>)>,
85}
86
87/// Per-property bits the controller keeps beside a device's view, so that
88/// setting them never copies the view's maps. Bit 0 stands for a value, bit 1
89/// for a `$target`.
90#[derive(Clone, Copy, Debug, Default)]
91struct Marks {
92    /// Arrived in the current burst.
93    burst: u8,
94    /// Arrived since the oldest re-sync window covering the node opened.
95    seen: u8,
96}
97
98/// One device, including those subscribed in tree or device mode whose
99/// `$state` has not arrived yet (`view.state` is `None`).
100#[derive(Clone, Debug)]
101struct Device {
102    view: DeviceView,
103    /// SHA-256 of the last `$description` payload.
104    raw_hash: Option<[u8; 32]>,
105    /// `content_hash` of the applied description.
106    content_hash: Option<[u8; 32]>,
107    /// `content_hash` of the last description announced.
108    announced: Option<[u8; 32]>,
109    ignored: Vec<Ignored>,
110    early: BTreeMap<(String, String), Early>,
111    /// By node, then property; absent means zero.
112    marks: BTreeMap<Id, BTreeMap<Id, Marks>>,
113    coverage: Coverage,
114    retained_total: u32,
115    retained_present: u32,
116    /// When missing values are reported, until [`Event::DeviceSynced`] fires.
117    sync_at: Option<Instant>,
118    sync_expired: bool,
119    absent: bool,
120    /// Re-sync windows open, oldest first.
121    windows: Vec<Window>,
122    /// Alerts heard since the oldest window carrying alerts opened.
123    seen_alerts: BTreeSet<Id>,
124    /// The description changed while the device was `sleeping`; reported on
125    /// the next announcement.
126    changed_while_sleeping: bool,
127    tree_complete: bool,
128    /// Device mode: the root this device's description names, and the
129    /// `{root}/$state` filter held for it.
130    root_filter: Option<(String, String)>,
131}
132
133impl Device {
134    fn new(key: &DevKey) -> Self {
135        Device {
136            view: DeviceView::new(key.id.clone(), key.domain.clone()),
137            raw_hash: None,
138            content_hash: None,
139            announced: None,
140            ignored: Vec::new(),
141            early: BTreeMap::new(),
142            marks: BTreeMap::new(),
143            coverage: Coverage::None,
144            retained_total: 0,
145            retained_present: 0,
146            sync_at: None,
147            sync_expired: false,
148            absent: false,
149            windows: Vec::new(),
150            seen_alerts: BTreeSet::new(),
151            changed_while_sleeping: false,
152            tree_complete: false,
153            root_filter: None,
154        }
155    }
156
157    fn touch(&mut self) {
158        self.view.confirmed = true;
159        self.absent = false;
160    }
161
162    /// The declared root, when it is another device.
163    fn root_id(&self) -> Option<&str> {
164        let root = self.view.description.as_ref()?.root.as_deref()?;
165        (root != self.view.id.as_str()).then_some(root)
166    }
167
168    fn missing(&self) -> u32 {
169        self.retained_total.saturating_sub(self.retained_present)
170    }
171
172    fn marks(&self, node: &str, prop: &str) -> Marks {
173        self.marks
174            .get(node)
175            .and_then(|m| m.get(prop))
176            .copied()
177            .unwrap_or_default()
178    }
179}
180
181/// Stage one: a device waiting for its `$state` and `$description`.
182#[derive(Clone, Debug)]
183struct Attrs {
184    filters: Vec<String>,
185    subscribed_at: Instant,
186    attempts: u8,
187    /// After a reconnect: a fresh `$description` is required.
188    refresh: bool,
189    /// `filters` include the whole device's property filters.
190    props: bool,
191}
192
193/// A re-sync of some of a device's nodes, from the SUBSCRIBE for their
194/// property filters until the replay of the `$description` marker queued
195/// after them.
196#[derive(Clone, Debug)]
197struct Window {
198    /// Number of the SUBSCRIBE carrying the marker.
199    marker: u64,
200    /// `None`: every node.
201    nodes: Option<Vec<Id>>,
202    /// The filters carry `$alert/+`.
203    alerts: bool,
204}
205
206/// Stage two: a device holding a batch slot while its retained property
207/// messages arrive.
208#[derive(Clone, Debug)]
209struct Burst {
210    /// `None` for the whole-device filters, else the nodes of one chunk.
211    nodes: Option<Vec<Id>>,
212    /// Retained properties the chunk declares.
213    expected: u32,
214    /// Values and targets of those properties received.
215    received: u32,
216    subscribed_at: Instant,
217    attempts: u8,
218    /// `received` of the attempt before, to stop a retry that made no progress.
219    last_received: Option<u32>,
220}
221
222impl Burst {
223    fn owed(&self) -> u64 {
224        (2 * u64::from(self.expected)).saturating_sub(u64::from(self.received))
225    }
226
227    fn covers(&self, node: &str) -> bool {
228        self.nodes
229            .as_ref()
230            .is_none_or(|ns| ns.iter().any(|n| n.as_str() == node))
231    }
232}
233
234/// A device waiting for a batch slot.
235#[derive(Clone, Copy, Debug, Default)]
236struct PendingEntry {
237    attempts: u8,
238    last_received: Option<u32>,
239}
240
241/// The filters one device holds in the ledger.
242#[derive(Clone, Debug, Default)]
243struct Holds {
244    /// `$state`, `$description` and, in device mode, the root's `$state`.
245    attrs: Vec<String>,
246    /// Property and `$target` filters.
247    props: Vec<String>,
248}
249
250/// The Homie 5 controller; see the [module documentation](self).
251#[derive(Debug)]
252pub struct Controller {
253    cfg: Config,
254    /// The [`Domain::Named`] domain.
255    named: Option<Arc<str>>,
256    /// `{domain}/5/` for a named domain.
257    prefix: String,
258    devices: BTreeMap<DevKey, Device>,
259    holds: BTreeMap<DevKey, Holds>,
260    /// Tree mode: children subscribed for each parent.
261    children: BTreeMap<DevKey, BTreeSet<Id>>,
262    /// Device mode: `$state` of the device's root.
263    aux_states: BTreeMap<Id, DeviceState>,
264    ledger: FilterLedger,
265    /// Wildcard mode: `{domain}/5/+/$state`.
266    discovery_filter: Option<String>,
267    awaiting: BTreeMap<DevKey, Attrs>,
268    pending: VecDeque<DevKey>,
269    pending_entries: BTreeMap<DevKey, PendingEntry>,
270    draining: BTreeMap<DevKey, Burst>,
271    commands: VecDeque<Command>,
272    events: VecDeque<Event>,
273    connected: bool,
274    ever_up: bool,
275    shut_down: bool,
276    absent_due: Option<Instant>,
277    retries: Vec<(Instant, Command)>,
278    backoff_ms: u64,
279    dropped: BTreeSet<DevKey>,
280    dropped_discovery: bool,
281    dropped_due: Option<Instant>,
282    /// At or before the earliest `sync_at` of any device.
283    sync_floor: Option<Instant>,
284    /// At or before the earliest `subscribed_at` in `awaiting` and `draining`.
285    stuck_floor: Option<Instant>,
286    /// Per device, the SUBSCRIBEs carrying its `$description` whose replay
287    /// has not arrived.
288    requests: BTreeMap<DevKey, replay::Requests>,
289    /// Number of the last SUBSCRIBE queued.
290    seq: u64,
291    /// SUBSCRIBEs numbered below it were processed by the broker.
292    floor: u64,
293    /// The last link-up may have resumed a session.
294    resumed: bool,
295    /// A message arrived with `retain: None`.
296    no_retain_bit: bool,
297}
298
299impl Controller {
300    /// A controller for `config`. Discovery is subscribed on the first
301    /// [`handle_link`](Self::handle_link) `Up`, or on the first message, which
302    /// counts as one.
303    pub fn new(config: Config) -> Result<Self, ConfigError> {
304        config.validate()?;
305        let (named, prefix) = match &config.domain {
306            Domain::Named(d) => (
307                Some(Arc::<str>::from(d.as_str())),
308                format!("{d}/{HOMIE_TOPIC_VERSION}/"),
309            ),
310            Domain::Any => (None, String::new()),
311        };
312        let discovery_filter = match (&config.discovery, &config.domain) {
313            (Discovery::Wildcard, Domain::Named(d)) => {
314                Some(format!("{d}/{HOMIE_TOPIC_VERSION}/+/$state"))
315            }
316            (Discovery::Wildcard, Domain::Any) => Some(format!("+/{HOMIE_TOPIC_VERSION}/+/$state")),
317            _ => None,
318        };
319        Ok(Controller {
320            ledger: FilterLedger::new(config.host_filters.iter().cloned()),
321            cfg: config,
322            named,
323            prefix,
324            devices: BTreeMap::new(),
325            holds: BTreeMap::new(),
326            children: BTreeMap::new(),
327            aux_states: BTreeMap::new(),
328            discovery_filter,
329            awaiting: BTreeMap::new(),
330            pending: VecDeque::new(),
331            pending_entries: BTreeMap::new(),
332            draining: BTreeMap::new(),
333            commands: VecDeque::new(),
334            events: VecDeque::new(),
335            connected: false,
336            ever_up: false,
337            shut_down: false,
338            absent_due: None,
339            retries: Vec::new(),
340            backoff_ms: RETRY_MIN_MS,
341            dropped: BTreeSet::new(),
342            dropped_discovery: false,
343            dropped_due: None,
344            sync_floor: None,
345            stuck_floor: None,
346            requests: BTreeMap::new(),
347            seq: 0,
348            floor: 0,
349            resumed: false,
350            no_retain_bit: false,
351        })
352    }
353
354    /// The configuration in use.
355    pub fn config(&self) -> &Config {
356        &self.cfg
357    }
358
359    /// The next command for the MQTT client.
360    pub fn poll_command(&mut self) -> Option<Command> {
361        self.commands.pop_front()
362    }
363
364    /// The next event.
365    pub fn poll_event(&mut self) -> Option<Event> {
366        self.events.pop_front()
367    }
368
369    /// When to call [`handle_timeout`](Self::handle_timeout) next, or `None`
370    /// when no timer is armed. The instant is never later than the earliest
371    /// deadline, and may be earlier when a deadline was disarmed since the
372    /// last `handle_timeout`, which then finds nothing due and recomputes it.
373    /// Constant time in the number of devices.
374    pub fn poll_timeout(&self) -> Option<Instant> {
375        if self.shut_down {
376            return None;
377        }
378        let mut next: Option<Instant> = None;
379        let mut consider = |t: Instant| next = Some(next.map_or(t, |n| n.min(t)));
380        if self.connected {
381            if let (Some(stuck), Some(t)) = (self.cfg.stuck_timeout_ms, self.stuck_floor) {
382                consider(t.add_ms(stuck));
383            }
384            for (due, _) in &self.retries {
385                consider(*due);
386            }
387            if let Some(t) = self.dropped_due {
388                consider(t);
389            }
390            if let Some(t) = self.absent_due {
391                consider(t);
392            }
393        }
394        if let Some(t) = self.sync_floor {
395            consider(t);
396        }
397        next
398    }
399
400    /// Lowers the sync floor to `t`.
401    fn floor_sync(&mut self, t: Instant) {
402        self.sync_floor = Some(self.sync_floor.map_or(t, |f| f.min(t)));
403    }
404
405    /// Lowers the stuck floor to `t`.
406    fn floor_stuck(&mut self, t: Instant) {
407        self.stuck_floor = Some(self.stuck_floor.map_or(t, |f| f.min(t)));
408    }
409
410    /// Sets both floors to the exact earliest deadlines.
411    fn recompute_floors(&mut self) {
412        self.sync_floor = self.devices.values().filter_map(|d| d.sync_at).min();
413        self.stuck_floor = self
414            .awaiting
415            .values()
416            .map(|a| a.subscribed_at)
417            .chain(self.draining.values().map(|b| b.subscribed_at))
418            .min();
419    }
420
421    /// True when `topic` is in the controller's domain (`{domain}/5/...`, or
422    /// any domain at version `5` for [`Domain::Any`]).
423    pub fn wants(&self, topic: &str) -> bool {
424        match self.named {
425            Some(_) => topic.starts_with(self.prefix.as_str()),
426            None => {
427                let mut levels = topic.splitn(3, '/');
428                let _domain = levels.next();
429                levels.next() == Some(HOMIE_TOPIC_VERSION) && levels.next().is_some()
430            }
431        }
432    }
433
434    /// True between a link-up and the next link-down, and before
435    /// [`shutdown`](Self::shutdown).
436    pub fn is_connected(&self) -> bool {
437        self.connected && !self.shut_down
438    }
439
440    /// The device with this id, once its first valid `$state` has arrived
441    /// (convention.md:272). With [`Domain::Any`], an id known in several domains
442    /// resolves to the first domain in byte order.
443    pub fn device(&self, id: &str) -> Option<&DeviceView> {
444        self.find(id).map(|(_, d)| &d.view)
445    }
446
447    /// Every device whose first valid `$state` has arrived, in id order.
448    pub fn devices(&self) -> impl Iterator<Item = &DeviceView> + '_ {
449        self.devices
450            .values()
451            .filter(|d| d.view.state.is_some())
452            .map(|d| &d.view)
453    }
454
455    /// Every device with this id whose first valid `$state` has arrived, one
456    /// per domain, in domain order. At most one with a [`Domain::Named`]
457    /// domain.
458    pub fn devices_with_id<'a>(&'a self, id: &'a str) -> impl Iterator<Item = &'a DeviceView> + 'a {
459        self.devices
460            .range::<dyn KeyRef, _>((Bound::Included(&(id, "") as &dyn KeyRef), Bound::Unbounded))
461            .take_while(move |(k, _)| k.id.as_str() == id)
462            .filter(|(_, d)| d.view.state.is_some())
463            .map(|(_, d)| &d.view)
464    }
465
466    /// The device's effective state (convention.md:262-267).
467    pub fn effective_state(&self, id: &str) -> Option<DeviceState> {
468        self.device(id).and_then(|v| v.effective)
469    }
470
471    /// True when the device `root` and every device its description declares
472    /// under it, transitively, have published a description. A declared device
473    /// counts whatever its `$state`.
474    pub fn is_tree_complete(&self, root: &str) -> bool {
475        let Some((key, _)) = self.find_any(root) else {
476            return false;
477        };
478        let key = key.clone();
479        self.tree_complete(&key)
480    }
481
482    /// Queues a `/set` for `addr` (QoS 0, not retained). `Ok` means validated
483    /// and queued; nothing confirms delivery (convention.md:474-475).
484    ///
485    /// QoS 0 follows framework.md:87 for every property. convention.md:45-49
486    /// recommends QoS 2 except for `/set` on non-retained properties; the eBus
487    /// specification decides where the two differ.
488    ///
489    /// Checked in order: the link is up; the device and property are known;
490    /// the property is settable; the device's effective state is `ready`
491    /// unless `opts.allow_not_ready`; unless `opts.unchecked`, the payload
492    /// passes [`datatype::parse_set_payload`] with the last valid value as the
493    /// step base. The payload is sent as given, except that `""` for a
494    /// `string` property is sent as a single `0x00` byte (convention.md:60-71).
495    pub fn set(
496        &mut self,
497        addr: &PropertyAddr,
498        payload: &str,
499        opts: SetOptions,
500    ) -> Result<(), SetError> {
501        if !self.is_connected() {
502            return Err(SetError::NotConnected);
503        }
504        let (key, dev) = self
505            .find(addr.device.as_str())
506            .ok_or(SetError::UnknownDevice)?;
507        let desc = dev
508            .view
509            .description
510            .as_deref()
511            .ok_or(SetError::UnknownProperty)?;
512        let slot = dev
513            .view
514            .props
515            .get(addr.node.as_str())
516            .and_then(|m| m.get(addr.property.as_str()))
517            .ok_or(SetError::UnknownProperty)?;
518        let decl = prop_decl(desc, slot).ok_or(SetError::UnknownProperty)?;
519        if !decl.settable {
520            return Err(SetError::NotSettable);
521        }
522        let effective = dev.view.effective.unwrap_or(DeviceState::Init);
523        if effective != DeviceState::Ready && !opts.allow_not_ready {
524            return Err(SetError::DeviceNotReady(effective));
525        }
526        if !opts.unchecked {
527            let current = slot
528                .view
529                .last_valid
530                .as_deref()
531                .and_then(|t| current_of(decl.datatype, t));
532            datatype::parse_set_payload(payload, decl.datatype, decl.format.as_deref(), current)
533                .map_err(SetError::Invalid)?;
534        }
535        let topic = crate::topic::property_set(
536            &key.domain,
537            key.id.as_str(),
538            addr.node.as_str(),
539            addr.property.as_str(),
540        );
541        let payload = if payload.is_empty() && decl.datatype == Datatype::String {
542            Vec::from(EMPTY_STRING_PAYLOAD)
543        } else {
544            Vec::from(payload.as_bytes())
545        };
546        crate::log::info!("settingProperty", topic = topic);
547        self.commands
548            .push_back(Command::publish(topic, payload, QoS::AtMostOnce, false));
549        Ok(())
550    }
551
552    /// Queues `payload` to `{domain}/5/$broadcast/{subtopic}` (QoS 0, not
553    /// retained), after checking that every subtopic level is a valid id
554    /// (convention.md:531-535).
555    pub fn broadcast(&mut self, subtopic: &str, payload: &str) -> Result<(), SetError> {
556        if !self.is_connected() {
557            return Err(SetError::NotConnected);
558        }
559        let Some(domain) = &self.named else {
560            return Err(SetError::InvalidTopic);
561        };
562        if !subtopic.split('/').all(is_valid_id) {
563            return Err(SetError::InvalidTopic);
564        }
565        let topic = crate::topic::broadcast(domain, subtopic);
566        crate::log::info!("broadcasting", topic = topic);
567        self.commands.push_back(Command::publish(
568            topic,
569            Vec::from(payload.as_bytes()),
570            QoS::AtMostOnce,
571            false,
572        ));
573        Ok(())
574    }
575
576    /// Unsubscribes every filter the controller holds, except host filters,
577    /// and stops: later input is ignored, no timer is armed, and
578    /// [`set`](Self::set) returns [`SetError::NotConnected`]. The views stay
579    /// readable.
580    pub fn shutdown(&mut self) {
581        if self.shut_down {
582            return;
583        }
584        crate::log::info!("stoppingController", devices = self.devices.len());
585        self.shut_down = true;
586        let cmd = self.ledger.release_all();
587        self.push(cmd);
588        self.holds.clear();
589        self.awaiting.clear();
590        self.pending.clear();
591        self.pending_entries.clear();
592        self.draining.clear();
593        self.retries.clear();
594        self.dropped.clear();
595        self.dropped_discovery = false;
596        self.dropped_due = None;
597        self.absent_due = None;
598        self.sync_floor = None;
599        self.stuck_floor = None;
600        self.requests.clear();
601        for d in self.devices.values_mut() {
602            d.sync_at = None;
603        }
604    }
605
606    // ----- lookups -----
607
608    /// A device with a valid `$state`, by id alone.
609    fn find(&self, id: &str) -> Option<(&DevKey, &Device)> {
610        match &self.named {
611            Some(dom) => self
612                .devices
613                .get_key_value(&(id, &**dom) as &dyn KeyRef)
614                .filter(|(_, d)| d.view.state.is_some()),
615            None => self
616                .devices
617                .range::<dyn KeyRef, _>((
618                    Bound::Included(&(id, "") as &dyn KeyRef),
619                    Bound::Unbounded,
620                ))
621                .take_while(|(k, _)| k.id.as_str() == id)
622                .find(|(_, d)| d.view.state.is_some()),
623        }
624    }
625
626    /// Any tracked device by id alone, heard or not.
627    fn find_any(&self, id: &str) -> Option<(&DevKey, &Device)> {
628        match &self.named {
629            Some(dom) => self.devices.get_key_value(&(id, &**dom) as &dyn KeyRef),
630            None => self
631                .devices
632                .range::<dyn KeyRef, _>((
633                    Bound::Included(&(id, "") as &dyn KeyRef),
634                    Bound::Unbounded,
635                ))
636                .next()
637                .filter(|(k, _)| k.id.as_str() == id),
638        }
639    }
640
641    fn key_of(&self, id: &str, domain: &str) -> Option<DevKey> {
642        self.devices
643            .get_key_value(&(id, domain) as &dyn KeyRef)
644            .map(|(k, _)| k.clone())
645    }
646
647    /// The key for `id` in the configured named domain.
648    fn named_key(&self, id: &Id) -> Option<DevKey> {
649        self.named.as_ref().map(|d| DevKey {
650            id: id.clone(),
651            domain: d.clone(),
652        })
653    }
654
655    // ----- filters and the ledger -----
656
657    fn topic_filter(domain: &str, id: &str, suffix: &str) -> String {
658        format!("{domain}/{HOMIE_TOPIC_VERSION}/{id}/{suffix}")
659    }
660
661    fn device_filter(key: &DevKey, suffix: &str) -> String {
662        Self::topic_filter(&key.domain, key.id.as_str(), suffix)
663    }
664
665    /// `$state` (except in wildcard mode) and `$description`, plus the root's
666    /// `$state` in device mode.
667    fn attr_filters(&self, key: &DevKey) -> Vec<String> {
668        let mut out = Vec::with_capacity(3);
669        if !matches!(self.cfg.discovery, Discovery::Wildcard) {
670            out.push(Self::device_filter(key, "$state"));
671        }
672        out.push(Self::device_filter(key, "$description"));
673        if let Some((_, f)) = self.devices.get(key).and_then(|d| d.root_filter.clone()) {
674            out.push(f);
675        }
676        out
677    }
678
679    /// The property filters for `nodes` (`None`: the whole device).
680    fn prop_filters(key: &DevKey, nodes: Option<&[Id]>) -> Vec<String> {
681        match nodes {
682            None => WHOLE_DEVICE
683                .iter()
684                .map(|s| Self::device_filter(key, s))
685                .collect(),
686            Some(nodes) => nodes
687                .iter()
688                .flat_map(|n| {
689                    [
690                        Self::device_filter(key, &format!("{n}/+")),
691                        Self::device_filter(key, &format!("{n}/+/$target")),
692                    ]
693                })
694                .collect(),
695        }
696    }
697
698    fn push(&mut self, cmd: Option<Command>) {
699        let Some(cmd) = cmd else {
700            return;
701        };
702        if let Command::Subscribe { filters, .. } = &cmd {
703            self.number_subscribe(filters);
704        }
705        self.commands.push_back(cmd);
706    }
707
708    /// Holds `attrs` and `props` for `key` and queues one SUBSCRIBE for them,
709    /// in the order given, followed with `marker` by its `$description` (the
710    /// end-of-burst marker). A filter the device already holds is sent again
711    /// without a new holder, so the broker replays its retained messages.
712    fn hold(&mut self, key: &DevKey, attrs: Vec<String>, props: Vec<String>, marker: bool) {
713        let qos = self.cfg.subscribe_qos;
714        let holds = self.holds.entry(key.clone()).or_default();
715        let mut order: Vec<String> = Vec::with_capacity(attrs.len() + props.len() + 1);
716        let mut fresh = Vec::new();
717        let mut again = Vec::new();
718        for (list, filters) in [(&mut holds.attrs, attrs), (&mut holds.props, props)] {
719            for f in filters {
720                order.push(f.clone());
721                if list.contains(&f) {
722                    again.push(f);
723                } else {
724                    list.push(f.clone());
725                    fresh.push((f, qos));
726                }
727            }
728        }
729        if marker {
730            let f = Self::device_filter(key, "$description");
731            order.push(f.clone());
732            again.push(f);
733        }
734        let mut filters = Vec::new();
735        for cmd in [
736            self.ledger.subscribe(fresh),
737            self.ledger.resubscribe(again.iter()),
738        ] {
739            if let Some(Command::Subscribe { filters: more, .. }) = cmd {
740                for (f, q) in more {
741                    match filters
742                        .iter_mut()
743                        .find(|(g, _): &&mut (String, QoS)| *g == f)
744                    {
745                        Some(e) => e.1 = e.1.max(q),
746                        None => filters.push((f, q)),
747                    }
748                }
749            }
750        }
751        if filters.is_empty() {
752            return;
753        }
754        filters.sort_by_key(|(f, _)| order.iter().position(|o| o == f));
755        self.push(Some(Command::subscribe(filters)));
756    }
757
758    /// Sends the SUBSCRIBE again for filters the device holds, so the broker
759    /// replays their retained messages.
760    fn resubscribe(&mut self, filters: &[String]) {
761        let cmd = self.ledger.resubscribe(filters.iter());
762        self.push(cmd);
763    }
764
765    fn release_props(&mut self, key: &DevKey) {
766        let Some(h) = self.holds.get_mut(key) else {
767            return;
768        };
769        let props = core::mem::take(&mut h.props);
770        let cmd = self.ledger.unsubscribe(props);
771        self.push(cmd);
772    }
773
774    fn release_all(&mut self, key: &DevKey) {
775        let Some(h) = self.holds.remove(key) else {
776            return;
777        };
778        let mut all = h.props;
779        all.extend(h.attrs);
780        let cmd = self.ledger.unsubscribe(all);
781        self.push(cmd);
782    }
783
784    // ----- state -----
785
786    /// `$state` of the device's declared root.
787    fn root_state(&self, key: &DevKey, dev: &Device) -> Option<DeviceState> {
788        let root = dev.root_id()?;
789        self.devices
790            .get(&(root, &*key.domain) as &dyn KeyRef)
791            .and_then(|r| r.view.state)
792            .or_else(|| self.aux_states.get(root).copied())
793    }
794
795    fn effective_of(&self, key: &DevKey) -> Option<DeviceState> {
796        let dev = self.devices.get(key)?;
797        let own = dev.view.state?;
798        Some(effective_state(self.root_state(key, dev), own))
799    }
800}
801
802/// The declaration of the property a slot stands for.
803fn prop_decl<'a>(
804    desc: &'a crate::description::DeviceDescription,
805    slot: &Slot,
806) -> Option<&'a PropertyDescription> {
807    desc.nodes
808        .get(slot.node_idx)?
809        .1
810        .properties
811        .get(slot.prop_idx)
812        .map(|(_, p)| p)
813}
814
815/// The step base [`datatype::parse_set_payload`] takes, from a value's text.
816fn current_of(datatype: Datatype, text: &str) -> Option<Current> {
817    match datatype {
818        Datatype::Integer => text.parse().ok().map(Current::Integer),
819        Datatype::Float => text.parse().ok().map(Current::Float),
820        _ => None,
821    }
822}
823
824/// A `/set` topic or filter: the device role's, never the controller's
825/// subscriptions.
826fn is_set_level(s: &str) -> bool {
827    s.ends_with("/set")
828}
829
830impl ebus_mqtt::Role for Controller {
831    fn handle_link(&mut self, now: Instant, link: ebus_mqtt::Link) {
832        Controller::handle_link(self, now, link);
833    }
834
835    fn handle_message(&mut self, now: Instant, msg: ebus_mqtt::Message<'_>) -> Handled {
836        Controller::handle_message(self, now, msg)
837    }
838
839    fn handle_command_failed(&mut self, now: Instant, cmd: Command) {
840        Controller::handle_command_failed(self, now, cmd);
841    }
842
843    fn handle_timeout(&mut self, now: Instant) {
844        Controller::handle_timeout(self, now);
845    }
846
847    fn handle_dropped(&mut self, now: Instant, topic: &str) {
848        Controller::handle_dropped(self, now, topic);
849    }
850
851    fn handle_lost(&mut self, topic: &str) {
852        Controller::handle_lost(self, topic);
853    }
854
855    fn poll_command(&mut self) -> Option<Command> {
856        Controller::poll_command(self)
857    }
858
859    fn poll_timeout(&self) -> Option<Instant> {
860        Controller::poll_timeout(self)
861    }
862
863    fn wants(&self, topic: &str) -> bool {
864        Controller::wants(self, topic)
865    }
866
867    /// The controller's commands are `/set` and `$broadcast` publishes in its
868    /// domain, and subscriptions to filters in its domain other than `/set`
869    /// filters.
870    fn owns(&self, cmd: &Command) -> bool {
871        match cmd {
872            Command::Publish { topic, .. } => {
873                self.wants(topic)
874                    && (is_set_level(topic) || topic.split('/').nth(2) == Some("$broadcast"))
875            }
876            Command::Subscribe { filters, .. } => filters
877                .iter()
878                .any(|(f, _)| self.wants(f) && !is_set_level(f)),
879            Command::Unsubscribe { filters, .. } => {
880                filters.iter().any(|f| self.wants(f) && !is_set_level(f))
881            }
882            _ => false,
883        }
884    }
885
886    fn holds(&self, filter: &str) -> bool {
887        self.ledger.count(filter) > 0
888    }
889}