Skip to main content

tasmor_lib/state/
device_state.rs

1// SPDX-License-Identifier: MPL-2.0
2// This Source Code Form is subject to the terms of the Mozilla Public
3// License, v. 2.0. If a copy of the MPL was not distributed with this
4// file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
6//! Device state tracking.
7//!
8//! This module provides [`DeviceState`], a comprehensive representation of a
9//! Tasmota device's current state. It tracks:
10//!
11//! - **Power state** for up to 8 relays (POWER1-POWER8)
12//! - **Light settings**: dimmer level, HSB color, color temperature
13//! - **Energy readings**: voltage, current, power consumption, energy totals
14//! - **System info**: uptime, Wi-Fi signal strength, free memory (read-only)
15//!
16//! # Design Philosophy
17//!
18//! All fields are `Option` types because device state may not be known until
19//! the device reports it via telemetry or command response. This allows
20//! partial state updates without losing existing information.
21//!
22//! # Usage with Events
23//!
24//! `DeviceState` works together with [`StateChange`](super::StateChange) to
25//! provide an event-driven state management system:
26//!
27//! ```
28//! use tasmor_lib::state::{DeviceState, StateChange};
29//! use tasmor_lib::types::{PowerState, Dimmer};
30//!
31//! let mut state = DeviceState::new();
32//!
33//! // Apply changes from telemetry
34//! let changes = vec![
35//!     StateChange::power(1, PowerState::On),
36//!     StateChange::dimmer(Dimmer::new(80).unwrap()),
37//! ];
38//!
39//! for change in &changes {
40//!     state.apply(change);
41//! }
42//!
43//! // Query current state
44//! assert_eq!(state.power(1), Some(PowerState::On));
45//! assert_eq!(state.dimmer().map(|d| d.value()), Some(80));
46//! ```
47
48use std::time::Duration;
49
50use crate::types::{
51    ColorTemperature, Dimmer, FadeDuration, HsbColor, PowerState, Scheme, TasmotaDateTime,
52    WakeupDuration,
53};
54
55use super::StateChange;
56
57/// System information from device telemetry.
58///
59/// Contains read-only diagnostic data like uptime and network status.
60/// These values change frequently and do **not** trigger callbacks when updated.
61///
62/// # Data Sources
63///
64/// - **MQTT telemetry**: uptime and `wifi_rssi` from `tele/<topic>/STATE`
65/// - **HTTP status**: All fields from `Status 0` command
66///
67/// # Examples
68///
69/// ```
70/// use std::time::Duration;
71/// use tasmor_lib::state::SystemInfo;
72///
73/// let info = SystemInfo::new()
74///     .with_uptime(Duration::from_secs(172800))
75///     .with_wifi_rssi(-60)
76///     .with_heap(25000);
77///
78/// assert_eq!(info.uptime(), Some(Duration::from_secs(172800)));
79/// ```
80#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
81pub struct SystemInfo {
82    /// Device uptime as duration.
83    #[serde(
84        serialize_with = "serialize_duration_as_secs",
85        deserialize_with = "deserialize_duration_from_secs"
86    )]
87    uptime: Option<Duration>,
88    /// Wi-Fi signal strength in dBm (typically -100 to 0, where 0 is best).
89    wifi_rssi: Option<i8>,
90    /// Free heap memory in kilobytes.
91    heap: Option<u32>,
92}
93
94// Serde requires &Option<T> for the serialize_with attribute, not Option<&T>
95#[allow(clippy::ref_option)]
96fn serialize_duration_as_secs<S>(
97    duration: &Option<Duration>,
98    serializer: S,
99) -> Result<S::Ok, S::Error>
100where
101    S: serde::Serializer,
102{
103    match duration {
104        Some(d) => serializer.serialize_some(&d.as_secs()),
105        None => serializer.serialize_none(),
106    }
107}
108
109fn deserialize_duration_from_secs<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
110where
111    D: serde::Deserializer<'de>,
112{
113    let opt: Option<u64> = serde::Deserialize::deserialize(deserializer)?;
114    Ok(opt.map(Duration::from_secs))
115}
116
117impl SystemInfo {
118    /// Creates a new empty system info.
119    #[must_use]
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Sets the uptime.
125    ///
126    /// # Examples
127    ///
128    /// ```
129    /// use std::time::Duration;
130    /// use tasmor_lib::state::SystemInfo;
131    ///
132    /// let info = SystemInfo::new().with_uptime(Duration::from_secs(86400));
133    /// assert_eq!(info.uptime(), Some(Duration::from_secs(86400)));
134    /// ```
135    #[must_use]
136    pub fn with_uptime(mut self, duration: Duration) -> Self {
137        self.uptime = Some(duration);
138        self
139    }
140
141    /// Sets the Wi-Fi RSSI in dBm.
142    #[must_use]
143    pub fn with_wifi_rssi(mut self, rssi: i8) -> Self {
144        self.wifi_rssi = Some(rssi);
145        self
146    }
147
148    /// Sets the free heap memory in kilobytes.
149    #[must_use]
150    pub fn with_heap(mut self, heap_kb: u32) -> Self {
151        self.heap = Some(heap_kb);
152        self
153    }
154
155    /// Returns the device uptime.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use std::time::Duration;
161    /// use tasmor_lib::state::SystemInfo;
162    ///
163    /// let info = SystemInfo::new().with_uptime(Duration::from_secs(172800));
164    ///
165    /// let uptime = info.uptime().unwrap();
166    /// println!("Uptime: {} days", uptime.as_secs() / 86400);
167    /// ```
168    #[must_use]
169    pub fn uptime(&self) -> Option<Duration> {
170        self.uptime
171    }
172
173    /// Returns the Wi-Fi signal strength in dBm.
174    ///
175    /// Typical values range from -100 (weak) to 0 (strongest).
176    /// A signal of -50 dBm or better is considered excellent.
177    #[must_use]
178    pub fn wifi_rssi(&self) -> Option<i8> {
179        self.wifi_rssi
180    }
181
182    /// Returns the free heap memory in kilobytes.
183    #[must_use]
184    pub fn heap(&self) -> Option<u32> {
185        self.heap
186    }
187
188    /// Updates fields from another `SystemInfo`, preserving existing values
189    /// when the new value is `None`.
190    pub fn merge(&mut self, other: &SystemInfo) {
191        if other.uptime.is_some() {
192            self.uptime = other.uptime;
193        }
194        if other.wifi_rssi.is_some() {
195            self.wifi_rssi = other.wifi_rssi;
196        }
197        if other.heap.is_some() {
198            self.heap = other.heap;
199        }
200    }
201
202    /// Returns `true` if all fields are `None`.
203    #[must_use]
204    pub fn is_empty(&self) -> bool {
205        self.uptime.is_none() && self.wifi_rssi.is_none() && self.heap.is_none()
206    }
207}
208
209/// Tracked state of a Tasmota device.
210///
211/// This struct maintains the current state of a device, including power states,
212/// dimmer level, color settings, and energy readings. All fields are optional
213/// because state may not be known until the device reports it.
214///
215/// # Maximum Relays
216///
217/// Tasmota supports up to 8 relays (POWER1-POWER8). The state tracks each
218/// relay independently.
219///
220/// # Examples
221///
222/// ```
223/// use tasmor_lib::state::DeviceState;
224/// use tasmor_lib::types::PowerState;
225///
226/// let mut state = DeviceState::new();
227/// state.set_power(1, PowerState::On);
228/// assert_eq!(state.power(1), Some(PowerState::On));
229/// ```
230#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
231pub struct DeviceState {
232    /// Power state for each relay (indexed 0-7 for POWER1-POWER8).
233    power: [Option<PowerState>; 8],
234    /// Dimmer level (0-100).
235    dimmer: Option<Dimmer>,
236    /// HSB color (hue, saturation, brightness).
237    hsb_color: Option<HsbColor>,
238    /// Color temperature in mireds (153-500).
239    color_temperature: Option<ColorTemperature>,
240    /// Light scheme/effect (0-4).
241    scheme: Option<Scheme>,
242    /// Wakeup duration in seconds (1-3000).
243    wakeup_duration: Option<WakeupDuration>,
244    /// Whether fade transitions are enabled.
245    fade_enabled: Option<bool>,
246    /// Fade transition duration (1-40 raw value, 0.5-20 seconds).
247    fade_duration: Option<FadeDuration>,
248    /// Current power consumption in Watts.
249    power_consumption: Option<f32>,
250    /// Current voltage in Volts.
251    voltage: Option<f32>,
252    /// Current in Amperes.
253    current: Option<f32>,
254    /// Apparent power in VA.
255    apparent_power: Option<f32>,
256    /// Reactive power in `VAr`.
257    reactive_power: Option<f32>,
258    /// Power factor (0-1).
259    power_factor: Option<f32>,
260    /// Energy consumed today in kWh.
261    energy_today: Option<f32>,
262    /// Energy consumed yesterday in kWh.
263    energy_yesterday: Option<f32>,
264    /// Energy total in kWh.
265    energy_total: Option<f32>,
266    /// Timestamp when total energy counting started.
267    total_start_time: Option<TasmotaDateTime>,
268    /// AC frequency in Hz. `None` for DC monitors or devices that do not report it.
269    frequency: Option<f32>,
270    /// System diagnostic information (uptime, Wi-Fi, memory).
271    ///
272    /// This is read-only data that does **not** trigger callbacks.
273    system_info: Option<SystemInfo>,
274}
275
276impl DeviceState {
277    /// Creates a new empty device state.
278    #[must_use]
279    pub fn new() -> Self {
280        Self::default()
281    }
282
283    // ========== Power State ==========
284
285    /// Gets the power state for a specific relay.
286    ///
287    /// # Arguments
288    ///
289    /// * `index` - The relay index (1-8)
290    ///
291    /// # Returns
292    ///
293    /// Returns `None` if the index is out of range (0 or >8) or if the
294    /// power state is unknown.
295    #[must_use]
296    pub fn power(&self, index: u8) -> Option<PowerState> {
297        if index == 0 || index > 8 {
298            return None;
299        }
300        self.power[usize::from(index - 1)]
301    }
302
303    /// Sets the power state for a specific relay.
304    ///
305    /// # Arguments
306    ///
307    /// * `index` - The relay index (1-8)
308    /// * `state` - The power state to set
309    ///
310    /// Does nothing if index is 0 or greater than 8.
311    pub fn set_power(&mut self, index: u8, state: PowerState) {
312        if index > 0 && index <= 8 {
313            self.power[usize::from(index - 1)] = Some(state);
314        }
315    }
316
317    /// Clears the power state for a specific relay.
318    pub fn clear_power(&mut self, index: u8) {
319        if index > 0 && index <= 8 {
320            self.power[usize::from(index - 1)] = None;
321        }
322    }
323
324    /// Returns all known power states as (index, state) pairs.
325    #[must_use]
326    pub fn all_power_states(&self) -> Vec<(u8, PowerState)> {
327        self.power
328            .iter()
329            .enumerate()
330            .filter_map(|(i, state)| {
331                state.map(|s| {
332                    // Safe: i is 0-7, so i+1 fits in u8
333                    #[allow(clippy::cast_possible_truncation)]
334                    let index = (i + 1) as u8;
335                    (index, s)
336                })
337            })
338            .collect()
339    }
340
341    /// Returns `true` if any relay is on.
342    #[must_use]
343    pub fn is_any_on(&self) -> bool {
344        self.power.iter().any(|s| matches!(s, Some(PowerState::On)))
345    }
346
347    // ========== Dimmer ==========
348
349    /// Gets the dimmer level.
350    #[must_use]
351    pub fn dimmer(&self) -> Option<Dimmer> {
352        self.dimmer
353    }
354
355    /// Sets the dimmer level.
356    pub fn set_dimmer(&mut self, value: Dimmer) {
357        self.dimmer = Some(value);
358    }
359
360    /// Clears the dimmer level.
361    pub fn clear_dimmer(&mut self) {
362        self.dimmer = None;
363    }
364
365    // ========== HSB Color ==========
366
367    /// Gets the HSB color.
368    #[must_use]
369    pub fn hsb_color(&self) -> Option<HsbColor> {
370        self.hsb_color
371    }
372
373    /// Sets the HSB color.
374    pub fn set_hsb_color(&mut self, color: HsbColor) {
375        self.hsb_color = Some(color);
376    }
377
378    /// Clears the HSB color.
379    pub fn clear_hsb_color(&mut self) {
380        self.hsb_color = None;
381    }
382
383    // ========== Color Temperature ==========
384
385    /// Gets the color temperature.
386    #[must_use]
387    pub fn color_temperature(&self) -> Option<ColorTemperature> {
388        self.color_temperature
389    }
390
391    /// Sets the color temperature.
392    pub fn set_color_temperature(&mut self, ct: ColorTemperature) {
393        self.color_temperature = Some(ct);
394    }
395
396    /// Clears the color temperature.
397    pub fn clear_color_temperature(&mut self) {
398        self.color_temperature = None;
399    }
400
401    // ========== Scheme ==========
402
403    /// Gets the light scheme/effect.
404    #[must_use]
405    pub fn scheme(&self) -> Option<Scheme> {
406        self.scheme
407    }
408
409    /// Sets the light scheme/effect.
410    pub fn set_scheme(&mut self, scheme: Scheme) {
411        self.scheme = Some(scheme);
412    }
413
414    /// Clears the scheme.
415    pub fn clear_scheme(&mut self) {
416        self.scheme = None;
417    }
418
419    // ========== Wakeup Duration ==========
420
421    /// Gets the wakeup duration.
422    #[must_use]
423    pub fn wakeup_duration(&self) -> Option<WakeupDuration> {
424        self.wakeup_duration
425    }
426
427    /// Sets the wakeup duration.
428    pub fn set_wakeup_duration(&mut self, duration: WakeupDuration) {
429        self.wakeup_duration = Some(duration);
430    }
431
432    /// Clears the wakeup duration.
433    pub fn clear_wakeup_duration(&mut self) {
434        self.wakeup_duration = None;
435    }
436
437    // ========== Fade Settings ==========
438
439    /// Gets whether fade transitions are enabled.
440    #[must_use]
441    pub fn fade_enabled(&self) -> Option<bool> {
442        self.fade_enabled
443    }
444
445    /// Sets whether fade transitions are enabled.
446    pub fn set_fade_enabled(&mut self, enabled: bool) {
447        self.fade_enabled = Some(enabled);
448    }
449
450    /// Clears the fade enabled state.
451    pub fn clear_fade_enabled(&mut self) {
452        self.fade_enabled = None;
453    }
454
455    /// Gets the fade transition duration.
456    #[must_use]
457    pub fn fade_duration(&self) -> Option<FadeDuration> {
458        self.fade_duration
459    }
460
461    /// Sets the fade transition duration.
462    pub fn set_fade_duration(&mut self, duration: FadeDuration) {
463        self.fade_duration = Some(duration);
464    }
465
466    /// Clears the fade duration.
467    pub fn clear_fade_duration(&mut self) {
468        self.fade_duration = None;
469    }
470
471    // ========== Energy Monitoring ==========
472
473    /// Gets the current power consumption in Watts.
474    #[must_use]
475    pub fn power_consumption(&self) -> Option<f32> {
476        self.power_consumption
477    }
478
479    /// Sets the power consumption.
480    pub fn set_power_consumption(&mut self, watts: f32) {
481        self.power_consumption = Some(watts);
482    }
483
484    /// Gets the current voltage in Volts.
485    #[must_use]
486    pub fn voltage(&self) -> Option<f32> {
487        self.voltage
488    }
489
490    /// Sets the voltage.
491    pub fn set_voltage(&mut self, volts: f32) {
492        self.voltage = Some(volts);
493    }
494
495    /// Gets the current in Amperes.
496    #[must_use]
497    pub fn current(&self) -> Option<f32> {
498        self.current
499    }
500
501    /// Sets the current.
502    pub fn set_current(&mut self, amps: f32) {
503        self.current = Some(amps);
504    }
505
506    /// Gets the total energy consumption in kWh.
507    #[must_use]
508    pub fn energy_total(&self) -> Option<f32> {
509        self.energy_total
510    }
511
512    /// Sets the total energy.
513    pub fn set_energy_total(&mut self, kwh: f32) {
514        self.energy_total = Some(kwh);
515    }
516
517    /// Gets the apparent power in VA.
518    #[must_use]
519    pub fn apparent_power(&self) -> Option<f32> {
520        self.apparent_power
521    }
522
523    /// Sets the apparent power.
524    pub fn set_apparent_power(&mut self, va: f32) {
525        self.apparent_power = Some(va);
526    }
527
528    /// Gets the reactive power in `VAr`.
529    #[must_use]
530    pub fn reactive_power(&self) -> Option<f32> {
531        self.reactive_power
532    }
533
534    /// Sets the reactive power.
535    pub fn set_reactive_power(&mut self, var: f32) {
536        self.reactive_power = Some(var);
537    }
538
539    /// Gets the power factor (0-1).
540    #[must_use]
541    pub fn power_factor(&self) -> Option<f32> {
542        self.power_factor
543    }
544
545    /// Sets the power factor.
546    pub fn set_power_factor(&mut self, factor: f32) {
547        self.power_factor = Some(factor);
548    }
549
550    /// Gets the energy consumed today in kWh.
551    #[must_use]
552    pub fn energy_today(&self) -> Option<f32> {
553        self.energy_today
554    }
555
556    /// Sets the energy consumed today.
557    pub fn set_energy_today(&mut self, kwh: f32) {
558        self.energy_today = Some(kwh);
559    }
560
561    /// Gets the energy consumed yesterday in kWh.
562    #[must_use]
563    pub fn energy_yesterday(&self) -> Option<f32> {
564        self.energy_yesterday
565    }
566
567    /// Sets the energy consumed yesterday.
568    pub fn set_energy_yesterday(&mut self, kwh: f32) {
569        self.energy_yesterday = Some(kwh);
570    }
571
572    /// Gets the timestamp when total energy counting started.
573    ///
574    /// Returns a [`TasmotaDateTime`] which provides both:
575    /// - `naive()` - the datetime without timezone (always available)
576    /// - `to_datetime()` - the timezone-aware datetime (if timezone was known)
577    #[must_use]
578    pub fn total_start_time(&self) -> Option<&TasmotaDateTime> {
579        self.total_start_time.as_ref()
580    }
581
582    /// Sets the timestamp when total energy counting started.
583    pub fn set_total_start_time(&mut self, time: TasmotaDateTime) {
584        self.total_start_time = Some(time);
585    }
586
587    /// Gets the AC frequency in Hz, or `None` for DC monitors or devices that do not report it.
588    #[must_use]
589    pub fn frequency(&self) -> Option<f32> {
590        self.frequency
591    }
592
593    /// Sets the AC frequency.
594    pub fn set_frequency(&mut self, hz: f32) {
595        self.frequency = Some(hz);
596    }
597
598    // ========== System Info ==========
599
600    /// Gets the system diagnostic information.
601    ///
602    /// System info includes uptime, Wi-Fi signal strength, and free memory.
603    /// This data does **not** trigger callbacks when updated.
604    ///
605    /// # Examples
606    ///
607    /// ```
608    /// use std::time::Duration;
609    /// use tasmor_lib::state::{DeviceState, SystemInfo};
610    ///
611    /// let mut state = DeviceState::new();
612    /// state.set_system_info(SystemInfo::new().with_uptime(Duration::from_secs(172800)));
613    ///
614    /// if let Some(info) = state.system_info() {
615    ///     let uptime = info.uptime().unwrap_or(Duration::ZERO);
616    ///     println!("Uptime: {} seconds", uptime.as_secs());
617    /// }
618    /// ```
619    #[must_use]
620    pub fn system_info(&self) -> Option<&SystemInfo> {
621        self.system_info.as_ref()
622    }
623
624    /// Sets the system diagnostic information.
625    pub fn set_system_info(&mut self, info: SystemInfo) {
626        self.system_info = Some(info);
627    }
628
629    /// Updates system information, merging with existing data.
630    ///
631    /// This preserves existing values when the new `SystemInfo` has `None` fields.
632    pub fn update_system_info(&mut self, info: &SystemInfo) {
633        if let Some(existing) = &mut self.system_info {
634            existing.merge(info);
635        } else {
636            self.system_info = Some(info.clone());
637        }
638    }
639
640    /// Returns the device uptime.
641    ///
642    /// This is a convenience method equivalent to
643    /// `state.system_info().and_then(|i| i.uptime())`.
644    ///
645    /// # Examples
646    ///
647    /// ```
648    /// use std::time::Duration;
649    /// use tasmor_lib::state::{DeviceState, SystemInfo};
650    ///
651    /// let mut state = DeviceState::new();
652    /// state.set_system_info(SystemInfo::new().with_uptime(Duration::from_secs(172800)));
653    ///
654    /// let uptime = state.uptime().unwrap();
655    /// println!("Uptime: {} days", uptime.as_secs() / 86400);
656    /// ```
657    #[must_use]
658    pub fn uptime(&self) -> Option<Duration> {
659        self.system_info.as_ref().and_then(SystemInfo::uptime)
660    }
661
662    // ========== State Changes ==========
663
664    /// Applies a state change and returns whether the state actually changed.
665    ///
666    /// # Returns
667    ///
668    /// Returns `true` if the state was modified, `false` if it was already
669    /// at the target value.
670    #[allow(clippy::too_many_lines)]
671    // Match arms for each StateChange variant are straightforward and splitting
672    // would reduce readability without improving maintainability
673    pub fn apply(&mut self, change: &StateChange) -> bool {
674        match change {
675            StateChange::Power { index, state } => {
676                let current = self.power(*index);
677                if current == Some(*state) {
678                    false
679                } else {
680                    self.set_power(*index, *state);
681                    true
682                }
683            }
684            StateChange::Dimmer(value) => {
685                if self.dimmer == Some(*value) {
686                    false
687                } else {
688                    self.dimmer = Some(*value);
689                    true
690                }
691            }
692            StateChange::HsbColor(color) => {
693                if self.hsb_color == Some(*color) {
694                    false
695                } else {
696                    self.hsb_color = Some(*color);
697                    true
698                }
699            }
700            StateChange::ColorTemperature(ct) => {
701                if self.color_temperature == Some(*ct) {
702                    false
703                } else {
704                    self.color_temperature = Some(*ct);
705                    true
706                }
707            }
708            StateChange::Scheme(scheme) => {
709                if self.scheme == Some(*scheme) {
710                    false
711                } else {
712                    self.scheme = Some(*scheme);
713                    true
714                }
715            }
716            StateChange::WakeupDuration(duration) => {
717                if self.wakeup_duration == Some(*duration) {
718                    false
719                } else {
720                    self.wakeup_duration = Some(*duration);
721                    true
722                }
723            }
724            StateChange::FadeEnabled(enabled) => {
725                if self.fade_enabled == Some(*enabled) {
726                    false
727                } else {
728                    self.fade_enabled = Some(*enabled);
729                    true
730                }
731            }
732            StateChange::FadeDuration(duration) => {
733                if self.fade_duration == Some(*duration) {
734                    false
735                } else {
736                    self.fade_duration = Some(*duration);
737                    true
738                }
739            }
740            StateChange::Energy {
741                power,
742                voltage,
743                current,
744                apparent_power,
745                reactive_power,
746                power_factor,
747                energy_today,
748                energy_yesterday,
749                energy_total,
750                total_start_time,
751                frequency,
752            } => {
753                let mut changed = false;
754
755                // Helper macro to update optional numeric fields
756                macro_rules! update_if_some {
757                    ($field:ident, $value:expr) => {
758                        if let Some(v) = $value {
759                            if self.$field != Some(*v) {
760                                self.$field = Some(*v);
761                                changed = true;
762                            }
763                        }
764                    };
765                }
766
767                update_if_some!(power_consumption, power);
768                update_if_some!(voltage, voltage);
769                update_if_some!(current, current);
770                update_if_some!(apparent_power, apparent_power);
771                update_if_some!(reactive_power, reactive_power);
772                update_if_some!(power_factor, power_factor);
773                update_if_some!(energy_today, energy_today);
774                update_if_some!(energy_yesterday, energy_yesterday);
775                update_if_some!(energy_total, energy_total);
776                update_if_some!(frequency, frequency);
777
778                // Handle datetime field separately (not a Copy type)
779                if let Some(time) = total_start_time
780                    && self.total_start_time.as_ref() != Some(time)
781                {
782                    self.total_start_time = Some(time.clone());
783                    changed = true;
784                }
785
786                changed
787            }
788            StateChange::Batch(changes) => {
789                let mut any_changed = false;
790                for c in changes {
791                    if self.apply(c) {
792                        any_changed = true;
793                    }
794                }
795                any_changed
796            }
797        }
798    }
799
800    /// Clears all state, resetting to unknown.
801    pub fn clear(&mut self) {
802        *self = Self::new();
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    #[test]
811    fn new_state_is_empty() {
812        let state = DeviceState::new();
813        assert!(state.power(1).is_none());
814        assert!(state.dimmer().is_none());
815        assert!(state.hsb_color().is_none());
816        assert!(state.color_temperature().is_none());
817        assert!(state.power_consumption().is_none());
818    }
819
820    #[test]
821    fn power_state_management() {
822        let mut state = DeviceState::new();
823
824        state.set_power(1, PowerState::On);
825        assert_eq!(state.power(1), Some(PowerState::On));
826        assert!(state.power(2).is_none());
827
828        state.set_power(2, PowerState::Off);
829        assert_eq!(state.power(2), Some(PowerState::Off));
830
831        state.clear_power(1);
832        assert!(state.power(1).is_none());
833    }
834
835    #[test]
836    fn power_index_bounds() {
837        let mut state = DeviceState::new();
838
839        // Index 0 is invalid
840        state.set_power(0, PowerState::On);
841        assert!(state.power(0).is_none());
842
843        // Index 9 is out of range
844        state.set_power(9, PowerState::On);
845        assert!(state.power(9).is_none());
846
847        // Index 8 is valid
848        state.set_power(8, PowerState::On);
849        assert_eq!(state.power(8), Some(PowerState::On));
850    }
851
852    #[test]
853    fn all_power_states() {
854        let mut state = DeviceState::new();
855        state.set_power(1, PowerState::On);
856        state.set_power(3, PowerState::Off);
857        state.set_power(5, PowerState::On);
858
859        let states = state.all_power_states();
860        assert_eq!(states.len(), 3);
861        assert!(states.contains(&(1, PowerState::On)));
862        assert!(states.contains(&(3, PowerState::Off)));
863        assert!(states.contains(&(5, PowerState::On)));
864    }
865
866    #[test]
867    fn is_any_on() {
868        let mut state = DeviceState::new();
869        assert!(!state.is_any_on());
870
871        state.set_power(1, PowerState::Off);
872        assert!(!state.is_any_on());
873
874        state.set_power(2, PowerState::On);
875        assert!(state.is_any_on());
876    }
877
878    #[test]
879    fn apply_power_change() {
880        let mut state = DeviceState::new();
881
882        let change = StateChange::Power {
883            index: 1,
884            state: PowerState::On,
885        };
886        assert!(state.apply(&change));
887        assert_eq!(state.power(1), Some(PowerState::On));
888
889        // Applying same state returns false
890        assert!(!state.apply(&change));
891    }
892
893    #[test]
894    fn apply_dimmer_change() {
895        let mut state = DeviceState::new();
896        let dimmer = Dimmer::new(75).unwrap();
897
898        let change = StateChange::Dimmer(dimmer);
899        assert!(state.apply(&change));
900        assert_eq!(state.dimmer(), Some(dimmer));
901    }
902
903    #[test]
904    fn apply_batch_changes() {
905        let mut state = DeviceState::new();
906
907        let changes = StateChange::Batch(vec![
908            StateChange::Power {
909                index: 1,
910                state: PowerState::On,
911            },
912            StateChange::Dimmer(Dimmer::new(50).unwrap()),
913        ]);
914
915        assert!(state.apply(&changes));
916        assert_eq!(state.power(1), Some(PowerState::On));
917        assert_eq!(state.dimmer(), Some(Dimmer::new(50).unwrap()));
918    }
919
920    #[test]
921    fn clear_resets_state() {
922        let mut state = DeviceState::new();
923        state.set_power(1, PowerState::On);
924        state.set_dimmer(Dimmer::new(75).unwrap());
925
926        state.clear();
927
928        assert!(state.power(1).is_none());
929        assert!(state.dimmer().is_none());
930    }
931
932    #[test]
933    fn apply_batch_with_hsb_color() {
934        use crate::types::HsbColor;
935
936        let mut state = DeviceState::new();
937        let hsb = HsbColor::new(360, 100, 100).unwrap();
938
939        let changes = StateChange::Batch(vec![
940            StateChange::Power {
941                index: 1,
942                state: PowerState::Off,
943            },
944            StateChange::Dimmer(Dimmer::new(100).unwrap()),
945            StateChange::HsbColor(hsb),
946        ]);
947
948        assert!(state.apply(&changes));
949        assert_eq!(state.power(1), Some(PowerState::Off));
950        assert_eq!(state.dimmer(), Some(Dimmer::new(100).unwrap()));
951
952        // Verify HsbColor was applied
953        let applied_hsb = state.hsb_color().expect("HsbColor should be set");
954        assert_eq!(applied_hsb.hue(), 360);
955        assert_eq!(applied_hsb.saturation(), 100);
956        assert_eq!(applied_hsb.brightness(), 100);
957    }
958
959    #[test]
960    fn apply_state_from_tasmota_telemetry() {
961        use crate::telemetry::TelemetryState;
962
963        // Real Tasmota RESULT JSON from logs
964        let json = r#"{
965            "Time":"2025-12-24T14:24:03",
966            "Uptime":"1T23:46:58",
967            "UptimeSec":172018,
968            "Heap":25,
969            "SleepMode":"Dynamic",
970            "Sleep":50,
971            "LoadAvg":19,
972            "MqttCount":1,
973            "POWER":"OFF",
974            "Dimmer":100,
975            "Color":"FF00000000",
976            "HSBColor":"360,100,100",
977            "White":0,
978            "CT":153,
979            "Channel":[100,0,0,0,0],
980            "Scheme":0,
981            "Fade":"ON",
982            "Speed":2,
983            "LedTable":"ON",
984            "Wifi":{"AP":1}
985        }"#;
986
987        // Parse telemetry
988        let telemetry: TelemetryState = serde_json::from_str(json).unwrap();
989        let changes = telemetry.to_state_changes();
990
991        // Apply to DeviceState
992        let mut state = DeviceState::new();
993        for change in changes {
994            state.apply(&change);
995        }
996
997        // Verify all fields are correctly set
998        assert_eq!(state.power(1), Some(PowerState::Off));
999        assert_eq!(state.dimmer(), Some(Dimmer::new(100).unwrap()));
1000
1001        // This is the key assertion - HSBColor must be set
1002        let hsb = state
1003            .hsb_color()
1004            .expect("HSBColor should be set from telemetry");
1005        assert_eq!(hsb.hue(), 360);
1006        assert_eq!(hsb.saturation(), 100);
1007        assert_eq!(hsb.brightness(), 100);
1008
1009        // Color temperature should also be set
1010        assert!(state.color_temperature().is_some());
1011        assert_eq!(state.color_temperature().unwrap().value(), 153);
1012
1013        // Fade should be enabled
1014        assert_eq!(state.fade_enabled(), Some(true));
1015
1016        // Fade duration should be set
1017        assert_eq!(state.fade_duration().map(|s| s.value()), Some(2));
1018    }
1019
1020    #[test]
1021    fn fade_getters_setters() {
1022        let mut state = DeviceState::new();
1023
1024        // Initially None
1025        assert!(state.fade_enabled().is_none());
1026        assert!(state.fade_duration().is_none());
1027
1028        // Set fade enabled
1029        state.set_fade_enabled(true);
1030        assert_eq!(state.fade_enabled(), Some(true));
1031
1032        state.set_fade_enabled(false);
1033        assert_eq!(state.fade_enabled(), Some(false));
1034
1035        // Set fade duration
1036        let duration = FadeDuration::from_raw(15).unwrap();
1037        state.set_fade_duration(duration);
1038        assert_eq!(state.fade_duration(), Some(duration));
1039
1040        // Clear
1041        state.clear_fade_enabled();
1042        state.clear_fade_duration();
1043        assert!(state.fade_enabled().is_none());
1044        assert!(state.fade_duration().is_none());
1045    }
1046
1047    #[test]
1048    fn apply_fade_changes() {
1049        let mut state = DeviceState::new();
1050
1051        // Apply fade enabled
1052        let change = StateChange::FadeEnabled(true);
1053        assert!(state.apply(&change));
1054        assert_eq!(state.fade_enabled(), Some(true));
1055
1056        // Applying same state returns false
1057        assert!(!state.apply(&change));
1058
1059        // Apply fade duration
1060        let duration = FadeDuration::from_raw(20).unwrap();
1061        let change = StateChange::FadeDuration(duration);
1062        assert!(state.apply(&change));
1063        assert_eq!(state.fade_duration(), Some(duration));
1064    }
1065
1066    // ========== SystemInfo Tests ==========
1067
1068    #[test]
1069    fn system_info_new_is_empty() {
1070        let info = SystemInfo::new();
1071        assert!(info.is_empty());
1072        assert!(info.uptime().is_none());
1073        assert!(info.wifi_rssi().is_none());
1074        assert!(info.heap().is_none());
1075    }
1076
1077    #[test]
1078    fn system_info_builder_pattern() {
1079        let info = SystemInfo::new()
1080            .with_uptime(Duration::from_secs(172800))
1081            .with_wifi_rssi(-55)
1082            .with_heap(25000);
1083
1084        assert!(!info.is_empty());
1085        assert_eq!(info.uptime(), Some(Duration::from_secs(172800)));
1086        assert_eq!(info.wifi_rssi(), Some(-55));
1087        assert_eq!(info.heap(), Some(25000));
1088    }
1089
1090    #[test]
1091    fn system_info_merge_preserves_existing() {
1092        let mut info = SystemInfo::new()
1093            .with_uptime(Duration::from_secs(100))
1094            .with_wifi_rssi(-50);
1095
1096        // Merge with partial update (only heap)
1097        let update = SystemInfo::new().with_heap(30000);
1098        info.merge(&update);
1099
1100        // Original values preserved, new value added
1101        assert_eq!(info.uptime(), Some(Duration::from_secs(100)));
1102        assert_eq!(info.wifi_rssi(), Some(-50));
1103        assert_eq!(info.heap(), Some(30000));
1104    }
1105
1106    #[test]
1107    fn system_info_merge_updates_values() {
1108        let mut info = SystemInfo::new()
1109            .with_uptime(Duration::from_secs(100))
1110            .with_wifi_rssi(-50);
1111
1112        // Merge with overlapping update
1113        let update = SystemInfo::new()
1114            .with_uptime(Duration::from_secs(200))
1115            .with_heap(30000);
1116        info.merge(&update);
1117
1118        // Updated values
1119        assert_eq!(info.uptime(), Some(Duration::from_secs(200)));
1120        assert_eq!(info.wifi_rssi(), Some(-50)); // Preserved
1121        assert_eq!(info.heap(), Some(30000));
1122    }
1123
1124    #[test]
1125    fn device_state_system_info_getters_setters() {
1126        let mut state = DeviceState::new();
1127
1128        // Initially None
1129        assert!(state.system_info().is_none());
1130        assert!(state.uptime().is_none());
1131
1132        // Set system info
1133        let info = SystemInfo::new().with_uptime(Duration::from_secs(172800));
1134        state.set_system_info(info);
1135
1136        assert!(state.system_info().is_some());
1137        assert_eq!(state.uptime(), Some(Duration::from_secs(172800)));
1138    }
1139
1140    #[test]
1141    fn device_state_update_system_info() {
1142        let mut state = DeviceState::new();
1143
1144        // Update on empty state
1145        let info1 = SystemInfo::new().with_uptime(Duration::from_secs(100));
1146        state.update_system_info(&info1);
1147        assert_eq!(state.uptime(), Some(Duration::from_secs(100)));
1148
1149        // Update with merge
1150        let info2 = SystemInfo::new().with_wifi_rssi(-55);
1151        state.update_system_info(&info2);
1152
1153        let sys_info = state.system_info().unwrap();
1154        assert_eq!(sys_info.uptime(), Some(Duration::from_secs(100))); // Preserved
1155        assert_eq!(sys_info.wifi_rssi(), Some(-55)); // Added
1156    }
1157
1158    #[test]
1159    fn device_state_clear_clears_system_info() {
1160        let mut state = DeviceState::new();
1161        state.set_system_info(SystemInfo::new().with_uptime(Duration::from_secs(172800)));
1162
1163        state.clear();
1164
1165        assert!(state.system_info().is_none());
1166    }
1167
1168    #[test]
1169    fn system_info_serialization() {
1170        let info = SystemInfo::new()
1171            .with_uptime(Duration::from_secs(172800))
1172            .with_wifi_rssi(-55)
1173            .with_heap(25000);
1174
1175        let json = serde_json::to_string(&info).unwrap();
1176        let deserialized: SystemInfo = serde_json::from_str(&json).unwrap();
1177
1178        assert_eq!(info, deserialized);
1179    }
1180
1181    #[test]
1182    fn device_state_with_system_info_serialization() {
1183        let mut state = DeviceState::new();
1184        state.set_power(1, PowerState::On);
1185        state.set_system_info(
1186            SystemInfo::new()
1187                .with_uptime(Duration::from_secs(172800))
1188                .with_wifi_rssi(-55),
1189        );
1190
1191        let json = serde_json::to_string(&state).unwrap();
1192        let deserialized: DeviceState = serde_json::from_str(&json).unwrap();
1193
1194        assert_eq!(state, deserialized);
1195        assert_eq!(deserialized.uptime(), Some(Duration::from_secs(172800)));
1196    }
1197}