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    /// System diagnostic information (uptime, Wi-Fi, memory).
269    ///
270    /// This is read-only data that does **not** trigger callbacks.
271    system_info: Option<SystemInfo>,
272}
273
274impl DeviceState {
275    /// Creates a new empty device state.
276    #[must_use]
277    pub fn new() -> Self {
278        Self::default()
279    }
280
281    // ========== Power State ==========
282
283    /// Gets the power state for a specific relay.
284    ///
285    /// # Arguments
286    ///
287    /// * `index` - The relay index (1-8)
288    ///
289    /// # Returns
290    ///
291    /// Returns `None` if the index is out of range (0 or >8) or if the
292    /// power state is unknown.
293    #[must_use]
294    pub fn power(&self, index: u8) -> Option<PowerState> {
295        if index == 0 || index > 8 {
296            return None;
297        }
298        self.power[usize::from(index - 1)]
299    }
300
301    /// Sets the power state for a specific relay.
302    ///
303    /// # Arguments
304    ///
305    /// * `index` - The relay index (1-8)
306    /// * `state` - The power state to set
307    ///
308    /// Does nothing if index is 0 or greater than 8.
309    pub fn set_power(&mut self, index: u8, state: PowerState) {
310        if index > 0 && index <= 8 {
311            self.power[usize::from(index - 1)] = Some(state);
312        }
313    }
314
315    /// Clears the power state for a specific relay.
316    pub fn clear_power(&mut self, index: u8) {
317        if index > 0 && index <= 8 {
318            self.power[usize::from(index - 1)] = None;
319        }
320    }
321
322    /// Returns all known power states as (index, state) pairs.
323    #[must_use]
324    pub fn all_power_states(&self) -> Vec<(u8, PowerState)> {
325        self.power
326            .iter()
327            .enumerate()
328            .filter_map(|(i, state)| {
329                state.map(|s| {
330                    // Safe: i is 0-7, so i+1 fits in u8
331                    #[allow(clippy::cast_possible_truncation)]
332                    let index = (i + 1) as u8;
333                    (index, s)
334                })
335            })
336            .collect()
337    }
338
339    /// Returns `true` if any relay is on.
340    #[must_use]
341    pub fn is_any_on(&self) -> bool {
342        self.power.iter().any(|s| matches!(s, Some(PowerState::On)))
343    }
344
345    // ========== Dimmer ==========
346
347    /// Gets the dimmer level.
348    #[must_use]
349    pub fn dimmer(&self) -> Option<Dimmer> {
350        self.dimmer
351    }
352
353    /// Sets the dimmer level.
354    pub fn set_dimmer(&mut self, value: Dimmer) {
355        self.dimmer = Some(value);
356    }
357
358    /// Clears the dimmer level.
359    pub fn clear_dimmer(&mut self) {
360        self.dimmer = None;
361    }
362
363    // ========== HSB Color ==========
364
365    /// Gets the HSB color.
366    #[must_use]
367    pub fn hsb_color(&self) -> Option<HsbColor> {
368        self.hsb_color
369    }
370
371    /// Sets the HSB color.
372    pub fn set_hsb_color(&mut self, color: HsbColor) {
373        self.hsb_color = Some(color);
374    }
375
376    /// Clears the HSB color.
377    pub fn clear_hsb_color(&mut self) {
378        self.hsb_color = None;
379    }
380
381    // ========== Color Temperature ==========
382
383    /// Gets the color temperature.
384    #[must_use]
385    pub fn color_temperature(&self) -> Option<ColorTemperature> {
386        self.color_temperature
387    }
388
389    /// Sets the color temperature.
390    pub fn set_color_temperature(&mut self, ct: ColorTemperature) {
391        self.color_temperature = Some(ct);
392    }
393
394    /// Clears the color temperature.
395    pub fn clear_color_temperature(&mut self) {
396        self.color_temperature = None;
397    }
398
399    // ========== Scheme ==========
400
401    /// Gets the light scheme/effect.
402    #[must_use]
403    pub fn scheme(&self) -> Option<Scheme> {
404        self.scheme
405    }
406
407    /// Sets the light scheme/effect.
408    pub fn set_scheme(&mut self, scheme: Scheme) {
409        self.scheme = Some(scheme);
410    }
411
412    /// Clears the scheme.
413    pub fn clear_scheme(&mut self) {
414        self.scheme = None;
415    }
416
417    // ========== Wakeup Duration ==========
418
419    /// Gets the wakeup duration.
420    #[must_use]
421    pub fn wakeup_duration(&self) -> Option<WakeupDuration> {
422        self.wakeup_duration
423    }
424
425    /// Sets the wakeup duration.
426    pub fn set_wakeup_duration(&mut self, duration: WakeupDuration) {
427        self.wakeup_duration = Some(duration);
428    }
429
430    /// Clears the wakeup duration.
431    pub fn clear_wakeup_duration(&mut self) {
432        self.wakeup_duration = None;
433    }
434
435    // ========== Fade Settings ==========
436
437    /// Gets whether fade transitions are enabled.
438    #[must_use]
439    pub fn fade_enabled(&self) -> Option<bool> {
440        self.fade_enabled
441    }
442
443    /// Sets whether fade transitions are enabled.
444    pub fn set_fade_enabled(&mut self, enabled: bool) {
445        self.fade_enabled = Some(enabled);
446    }
447
448    /// Clears the fade enabled state.
449    pub fn clear_fade_enabled(&mut self) {
450        self.fade_enabled = None;
451    }
452
453    /// Gets the fade transition duration.
454    #[must_use]
455    pub fn fade_duration(&self) -> Option<FadeDuration> {
456        self.fade_duration
457    }
458
459    /// Sets the fade transition duration.
460    pub fn set_fade_duration(&mut self, duration: FadeDuration) {
461        self.fade_duration = Some(duration);
462    }
463
464    /// Clears the fade duration.
465    pub fn clear_fade_duration(&mut self) {
466        self.fade_duration = None;
467    }
468
469    // ========== Energy Monitoring ==========
470
471    /// Gets the current power consumption in Watts.
472    #[must_use]
473    pub fn power_consumption(&self) -> Option<f32> {
474        self.power_consumption
475    }
476
477    /// Sets the power consumption.
478    pub fn set_power_consumption(&mut self, watts: f32) {
479        self.power_consumption = Some(watts);
480    }
481
482    /// Gets the current voltage in Volts.
483    #[must_use]
484    pub fn voltage(&self) -> Option<f32> {
485        self.voltage
486    }
487
488    /// Sets the voltage.
489    pub fn set_voltage(&mut self, volts: f32) {
490        self.voltage = Some(volts);
491    }
492
493    /// Gets the current in Amperes.
494    #[must_use]
495    pub fn current(&self) -> Option<f32> {
496        self.current
497    }
498
499    /// Sets the current.
500    pub fn set_current(&mut self, amps: f32) {
501        self.current = Some(amps);
502    }
503
504    /// Gets the total energy consumption in kWh.
505    #[must_use]
506    pub fn energy_total(&self) -> Option<f32> {
507        self.energy_total
508    }
509
510    /// Sets the total energy.
511    pub fn set_energy_total(&mut self, kwh: f32) {
512        self.energy_total = Some(kwh);
513    }
514
515    /// Gets the apparent power in VA.
516    #[must_use]
517    pub fn apparent_power(&self) -> Option<f32> {
518        self.apparent_power
519    }
520
521    /// Sets the apparent power.
522    pub fn set_apparent_power(&mut self, va: f32) {
523        self.apparent_power = Some(va);
524    }
525
526    /// Gets the reactive power in `VAr`.
527    #[must_use]
528    pub fn reactive_power(&self) -> Option<f32> {
529        self.reactive_power
530    }
531
532    /// Sets the reactive power.
533    pub fn set_reactive_power(&mut self, var: f32) {
534        self.reactive_power = Some(var);
535    }
536
537    /// Gets the power factor (0-1).
538    #[must_use]
539    pub fn power_factor(&self) -> Option<f32> {
540        self.power_factor
541    }
542
543    /// Sets the power factor.
544    pub fn set_power_factor(&mut self, factor: f32) {
545        self.power_factor = Some(factor);
546    }
547
548    /// Gets the energy consumed today in kWh.
549    #[must_use]
550    pub fn energy_today(&self) -> Option<f32> {
551        self.energy_today
552    }
553
554    /// Sets the energy consumed today.
555    pub fn set_energy_today(&mut self, kwh: f32) {
556        self.energy_today = Some(kwh);
557    }
558
559    /// Gets the energy consumed yesterday in kWh.
560    #[must_use]
561    pub fn energy_yesterday(&self) -> Option<f32> {
562        self.energy_yesterday
563    }
564
565    /// Sets the energy consumed yesterday.
566    pub fn set_energy_yesterday(&mut self, kwh: f32) {
567        self.energy_yesterday = Some(kwh);
568    }
569
570    /// Gets the timestamp when total energy counting started.
571    ///
572    /// Returns a [`TasmotaDateTime`] which provides both:
573    /// - `naive()` - the datetime without timezone (always available)
574    /// - `to_datetime()` - the timezone-aware datetime (if timezone was known)
575    #[must_use]
576    pub fn total_start_time(&self) -> Option<&TasmotaDateTime> {
577        self.total_start_time.as_ref()
578    }
579
580    /// Sets the timestamp when total energy counting started.
581    pub fn set_total_start_time(&mut self, time: TasmotaDateTime) {
582        self.total_start_time = Some(time);
583    }
584
585    // ========== System Info ==========
586
587    /// Gets the system diagnostic information.
588    ///
589    /// System info includes uptime, Wi-Fi signal strength, and free memory.
590    /// This data does **not** trigger callbacks when updated.
591    ///
592    /// # Examples
593    ///
594    /// ```
595    /// use std::time::Duration;
596    /// use tasmor_lib::state::{DeviceState, SystemInfo};
597    ///
598    /// let mut state = DeviceState::new();
599    /// state.set_system_info(SystemInfo::new().with_uptime(Duration::from_secs(172800)));
600    ///
601    /// if let Some(info) = state.system_info() {
602    ///     let uptime = info.uptime().unwrap_or(Duration::ZERO);
603    ///     println!("Uptime: {} seconds", uptime.as_secs());
604    /// }
605    /// ```
606    #[must_use]
607    pub fn system_info(&self) -> Option<&SystemInfo> {
608        self.system_info.as_ref()
609    }
610
611    /// Sets the system diagnostic information.
612    pub fn set_system_info(&mut self, info: SystemInfo) {
613        self.system_info = Some(info);
614    }
615
616    /// Updates system information, merging with existing data.
617    ///
618    /// This preserves existing values when the new `SystemInfo` has `None` fields.
619    pub fn update_system_info(&mut self, info: &SystemInfo) {
620        if let Some(existing) = &mut self.system_info {
621            existing.merge(info);
622        } else {
623            self.system_info = Some(info.clone());
624        }
625    }
626
627    /// Returns the device uptime.
628    ///
629    /// This is a convenience method equivalent to
630    /// `state.system_info().and_then(|i| i.uptime())`.
631    ///
632    /// # Examples
633    ///
634    /// ```
635    /// use std::time::Duration;
636    /// use tasmor_lib::state::{DeviceState, SystemInfo};
637    ///
638    /// let mut state = DeviceState::new();
639    /// state.set_system_info(SystemInfo::new().with_uptime(Duration::from_secs(172800)));
640    ///
641    /// let uptime = state.uptime().unwrap();
642    /// println!("Uptime: {} days", uptime.as_secs() / 86400);
643    /// ```
644    #[must_use]
645    pub fn uptime(&self) -> Option<Duration> {
646        self.system_info.as_ref().and_then(SystemInfo::uptime)
647    }
648
649    // ========== State Changes ==========
650
651    /// Applies a state change and returns whether the state actually changed.
652    ///
653    /// # Returns
654    ///
655    /// Returns `true` if the state was modified, `false` if it was already
656    /// at the target value.
657    #[allow(clippy::too_many_lines)]
658    // Match arms for each StateChange variant are straightforward and splitting
659    // would reduce readability without improving maintainability
660    pub fn apply(&mut self, change: &StateChange) -> bool {
661        match change {
662            StateChange::Power { index, state } => {
663                let current = self.power(*index);
664                if current == Some(*state) {
665                    false
666                } else {
667                    self.set_power(*index, *state);
668                    true
669                }
670            }
671            StateChange::Dimmer(value) => {
672                if self.dimmer == Some(*value) {
673                    false
674                } else {
675                    self.dimmer = Some(*value);
676                    true
677                }
678            }
679            StateChange::HsbColor(color) => {
680                if self.hsb_color == Some(*color) {
681                    false
682                } else {
683                    self.hsb_color = Some(*color);
684                    true
685                }
686            }
687            StateChange::ColorTemperature(ct) => {
688                if self.color_temperature == Some(*ct) {
689                    false
690                } else {
691                    self.color_temperature = Some(*ct);
692                    true
693                }
694            }
695            StateChange::Scheme(scheme) => {
696                if self.scheme == Some(*scheme) {
697                    false
698                } else {
699                    self.scheme = Some(*scheme);
700                    true
701                }
702            }
703            StateChange::WakeupDuration(duration) => {
704                if self.wakeup_duration == Some(*duration) {
705                    false
706                } else {
707                    self.wakeup_duration = Some(*duration);
708                    true
709                }
710            }
711            StateChange::FadeEnabled(enabled) => {
712                if self.fade_enabled == Some(*enabled) {
713                    false
714                } else {
715                    self.fade_enabled = Some(*enabled);
716                    true
717                }
718            }
719            StateChange::FadeDuration(duration) => {
720                if self.fade_duration == Some(*duration) {
721                    false
722                } else {
723                    self.fade_duration = Some(*duration);
724                    true
725                }
726            }
727            StateChange::Energy {
728                power,
729                voltage,
730                current,
731                apparent_power,
732                reactive_power,
733                power_factor,
734                energy_today,
735                energy_yesterday,
736                energy_total,
737                total_start_time,
738            } => {
739                let mut changed = false;
740
741                // Helper macro to update optional numeric fields
742                macro_rules! update_if_some {
743                    ($field:ident, $value:expr) => {
744                        if let Some(v) = $value {
745                            if self.$field != Some(*v) {
746                                self.$field = Some(*v);
747                                changed = true;
748                            }
749                        }
750                    };
751                }
752
753                update_if_some!(power_consumption, power);
754                update_if_some!(voltage, voltage);
755                update_if_some!(current, current);
756                update_if_some!(apparent_power, apparent_power);
757                update_if_some!(reactive_power, reactive_power);
758                update_if_some!(power_factor, power_factor);
759                update_if_some!(energy_today, energy_today);
760                update_if_some!(energy_yesterday, energy_yesterday);
761                update_if_some!(energy_total, energy_total);
762
763                // Handle datetime field separately (not a Copy type)
764                if let Some(time) = total_start_time
765                    && self.total_start_time.as_ref() != Some(time)
766                {
767                    self.total_start_time = Some(time.clone());
768                    changed = true;
769                }
770
771                changed
772            }
773            StateChange::Batch(changes) => {
774                let mut any_changed = false;
775                for c in changes {
776                    if self.apply(c) {
777                        any_changed = true;
778                    }
779                }
780                any_changed
781            }
782        }
783    }
784
785    /// Clears all state, resetting to unknown.
786    pub fn clear(&mut self) {
787        *self = Self::new();
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    #[test]
796    fn new_state_is_empty() {
797        let state = DeviceState::new();
798        assert!(state.power(1).is_none());
799        assert!(state.dimmer().is_none());
800        assert!(state.hsb_color().is_none());
801        assert!(state.color_temperature().is_none());
802        assert!(state.power_consumption().is_none());
803    }
804
805    #[test]
806    fn power_state_management() {
807        let mut state = DeviceState::new();
808
809        state.set_power(1, PowerState::On);
810        assert_eq!(state.power(1), Some(PowerState::On));
811        assert!(state.power(2).is_none());
812
813        state.set_power(2, PowerState::Off);
814        assert_eq!(state.power(2), Some(PowerState::Off));
815
816        state.clear_power(1);
817        assert!(state.power(1).is_none());
818    }
819
820    #[test]
821    fn power_index_bounds() {
822        let mut state = DeviceState::new();
823
824        // Index 0 is invalid
825        state.set_power(0, PowerState::On);
826        assert!(state.power(0).is_none());
827
828        // Index 9 is out of range
829        state.set_power(9, PowerState::On);
830        assert!(state.power(9).is_none());
831
832        // Index 8 is valid
833        state.set_power(8, PowerState::On);
834        assert_eq!(state.power(8), Some(PowerState::On));
835    }
836
837    #[test]
838    fn all_power_states() {
839        let mut state = DeviceState::new();
840        state.set_power(1, PowerState::On);
841        state.set_power(3, PowerState::Off);
842        state.set_power(5, PowerState::On);
843
844        let states = state.all_power_states();
845        assert_eq!(states.len(), 3);
846        assert!(states.contains(&(1, PowerState::On)));
847        assert!(states.contains(&(3, PowerState::Off)));
848        assert!(states.contains(&(5, PowerState::On)));
849    }
850
851    #[test]
852    fn is_any_on() {
853        let mut state = DeviceState::new();
854        assert!(!state.is_any_on());
855
856        state.set_power(1, PowerState::Off);
857        assert!(!state.is_any_on());
858
859        state.set_power(2, PowerState::On);
860        assert!(state.is_any_on());
861    }
862
863    #[test]
864    fn apply_power_change() {
865        let mut state = DeviceState::new();
866
867        let change = StateChange::Power {
868            index: 1,
869            state: PowerState::On,
870        };
871        assert!(state.apply(&change));
872        assert_eq!(state.power(1), Some(PowerState::On));
873
874        // Applying same state returns false
875        assert!(!state.apply(&change));
876    }
877
878    #[test]
879    fn apply_dimmer_change() {
880        let mut state = DeviceState::new();
881        let dimmer = Dimmer::new(75).unwrap();
882
883        let change = StateChange::Dimmer(dimmer);
884        assert!(state.apply(&change));
885        assert_eq!(state.dimmer(), Some(dimmer));
886    }
887
888    #[test]
889    fn apply_batch_changes() {
890        let mut state = DeviceState::new();
891
892        let changes = StateChange::Batch(vec![
893            StateChange::Power {
894                index: 1,
895                state: PowerState::On,
896            },
897            StateChange::Dimmer(Dimmer::new(50).unwrap()),
898        ]);
899
900        assert!(state.apply(&changes));
901        assert_eq!(state.power(1), Some(PowerState::On));
902        assert_eq!(state.dimmer(), Some(Dimmer::new(50).unwrap()));
903    }
904
905    #[test]
906    fn clear_resets_state() {
907        let mut state = DeviceState::new();
908        state.set_power(1, PowerState::On);
909        state.set_dimmer(Dimmer::new(75).unwrap());
910
911        state.clear();
912
913        assert!(state.power(1).is_none());
914        assert!(state.dimmer().is_none());
915    }
916
917    #[test]
918    fn apply_batch_with_hsb_color() {
919        use crate::types::HsbColor;
920
921        let mut state = DeviceState::new();
922        let hsb = HsbColor::new(360, 100, 100).unwrap();
923
924        let changes = StateChange::Batch(vec![
925            StateChange::Power {
926                index: 1,
927                state: PowerState::Off,
928            },
929            StateChange::Dimmer(Dimmer::new(100).unwrap()),
930            StateChange::HsbColor(hsb),
931        ]);
932
933        assert!(state.apply(&changes));
934        assert_eq!(state.power(1), Some(PowerState::Off));
935        assert_eq!(state.dimmer(), Some(Dimmer::new(100).unwrap()));
936
937        // Verify HsbColor was applied
938        let applied_hsb = state.hsb_color().expect("HsbColor should be set");
939        assert_eq!(applied_hsb.hue(), 360);
940        assert_eq!(applied_hsb.saturation(), 100);
941        assert_eq!(applied_hsb.brightness(), 100);
942    }
943
944    #[test]
945    fn apply_state_from_tasmota_telemetry() {
946        use crate::telemetry::TelemetryState;
947
948        // Real Tasmota RESULT JSON from logs
949        let json = r#"{
950            "Time":"2025-12-24T14:24:03",
951            "Uptime":"1T23:46:58",
952            "UptimeSec":172018,
953            "Heap":25,
954            "SleepMode":"Dynamic",
955            "Sleep":50,
956            "LoadAvg":19,
957            "MqttCount":1,
958            "POWER":"OFF",
959            "Dimmer":100,
960            "Color":"FF00000000",
961            "HSBColor":"360,100,100",
962            "White":0,
963            "CT":153,
964            "Channel":[100,0,0,0,0],
965            "Scheme":0,
966            "Fade":"ON",
967            "Speed":2,
968            "LedTable":"ON",
969            "Wifi":{"AP":1}
970        }"#;
971
972        // Parse telemetry
973        let telemetry: TelemetryState = serde_json::from_str(json).unwrap();
974        let changes = telemetry.to_state_changes();
975
976        // Apply to DeviceState
977        let mut state = DeviceState::new();
978        for change in changes {
979            state.apply(&change);
980        }
981
982        // Verify all fields are correctly set
983        assert_eq!(state.power(1), Some(PowerState::Off));
984        assert_eq!(state.dimmer(), Some(Dimmer::new(100).unwrap()));
985
986        // This is the key assertion - HSBColor must be set
987        let hsb = state
988            .hsb_color()
989            .expect("HSBColor should be set from telemetry");
990        assert_eq!(hsb.hue(), 360);
991        assert_eq!(hsb.saturation(), 100);
992        assert_eq!(hsb.brightness(), 100);
993
994        // Color temperature should also be set
995        assert!(state.color_temperature().is_some());
996        assert_eq!(state.color_temperature().unwrap().value(), 153);
997
998        // Fade should be enabled
999        assert_eq!(state.fade_enabled(), Some(true));
1000
1001        // Fade duration should be set
1002        assert_eq!(state.fade_duration().map(|s| s.value()), Some(2));
1003    }
1004
1005    #[test]
1006    fn fade_getters_setters() {
1007        let mut state = DeviceState::new();
1008
1009        // Initially None
1010        assert!(state.fade_enabled().is_none());
1011        assert!(state.fade_duration().is_none());
1012
1013        // Set fade enabled
1014        state.set_fade_enabled(true);
1015        assert_eq!(state.fade_enabled(), Some(true));
1016
1017        state.set_fade_enabled(false);
1018        assert_eq!(state.fade_enabled(), Some(false));
1019
1020        // Set fade duration
1021        let duration = FadeDuration::from_raw(15).unwrap();
1022        state.set_fade_duration(duration);
1023        assert_eq!(state.fade_duration(), Some(duration));
1024
1025        // Clear
1026        state.clear_fade_enabled();
1027        state.clear_fade_duration();
1028        assert!(state.fade_enabled().is_none());
1029        assert!(state.fade_duration().is_none());
1030    }
1031
1032    #[test]
1033    fn apply_fade_changes() {
1034        let mut state = DeviceState::new();
1035
1036        // Apply fade enabled
1037        let change = StateChange::FadeEnabled(true);
1038        assert!(state.apply(&change));
1039        assert_eq!(state.fade_enabled(), Some(true));
1040
1041        // Applying same state returns false
1042        assert!(!state.apply(&change));
1043
1044        // Apply fade duration
1045        let duration = FadeDuration::from_raw(20).unwrap();
1046        let change = StateChange::FadeDuration(duration);
1047        assert!(state.apply(&change));
1048        assert_eq!(state.fade_duration(), Some(duration));
1049    }
1050
1051    // ========== SystemInfo Tests ==========
1052
1053    #[test]
1054    fn system_info_new_is_empty() {
1055        let info = SystemInfo::new();
1056        assert!(info.is_empty());
1057        assert!(info.uptime().is_none());
1058        assert!(info.wifi_rssi().is_none());
1059        assert!(info.heap().is_none());
1060    }
1061
1062    #[test]
1063    fn system_info_builder_pattern() {
1064        let info = SystemInfo::new()
1065            .with_uptime(Duration::from_secs(172800))
1066            .with_wifi_rssi(-55)
1067            .with_heap(25000);
1068
1069        assert!(!info.is_empty());
1070        assert_eq!(info.uptime(), Some(Duration::from_secs(172800)));
1071        assert_eq!(info.wifi_rssi(), Some(-55));
1072        assert_eq!(info.heap(), Some(25000));
1073    }
1074
1075    #[test]
1076    fn system_info_merge_preserves_existing() {
1077        let mut info = SystemInfo::new()
1078            .with_uptime(Duration::from_secs(100))
1079            .with_wifi_rssi(-50);
1080
1081        // Merge with partial update (only heap)
1082        let update = SystemInfo::new().with_heap(30000);
1083        info.merge(&update);
1084
1085        // Original values preserved, new value added
1086        assert_eq!(info.uptime(), Some(Duration::from_secs(100)));
1087        assert_eq!(info.wifi_rssi(), Some(-50));
1088        assert_eq!(info.heap(), Some(30000));
1089    }
1090
1091    #[test]
1092    fn system_info_merge_updates_values() {
1093        let mut info = SystemInfo::new()
1094            .with_uptime(Duration::from_secs(100))
1095            .with_wifi_rssi(-50);
1096
1097        // Merge with overlapping update
1098        let update = SystemInfo::new()
1099            .with_uptime(Duration::from_secs(200))
1100            .with_heap(30000);
1101        info.merge(&update);
1102
1103        // Updated values
1104        assert_eq!(info.uptime(), Some(Duration::from_secs(200)));
1105        assert_eq!(info.wifi_rssi(), Some(-50)); // Preserved
1106        assert_eq!(info.heap(), Some(30000));
1107    }
1108
1109    #[test]
1110    fn device_state_system_info_getters_setters() {
1111        let mut state = DeviceState::new();
1112
1113        // Initially None
1114        assert!(state.system_info().is_none());
1115        assert!(state.uptime().is_none());
1116
1117        // Set system info
1118        let info = SystemInfo::new().with_uptime(Duration::from_secs(172800));
1119        state.set_system_info(info);
1120
1121        assert!(state.system_info().is_some());
1122        assert_eq!(state.uptime(), Some(Duration::from_secs(172800)));
1123    }
1124
1125    #[test]
1126    fn device_state_update_system_info() {
1127        let mut state = DeviceState::new();
1128
1129        // Update on empty state
1130        let info1 = SystemInfo::new().with_uptime(Duration::from_secs(100));
1131        state.update_system_info(&info1);
1132        assert_eq!(state.uptime(), Some(Duration::from_secs(100)));
1133
1134        // Update with merge
1135        let info2 = SystemInfo::new().with_wifi_rssi(-55);
1136        state.update_system_info(&info2);
1137
1138        let sys_info = state.system_info().unwrap();
1139        assert_eq!(sys_info.uptime(), Some(Duration::from_secs(100))); // Preserved
1140        assert_eq!(sys_info.wifi_rssi(), Some(-55)); // Added
1141    }
1142
1143    #[test]
1144    fn device_state_clear_clears_system_info() {
1145        let mut state = DeviceState::new();
1146        state.set_system_info(SystemInfo::new().with_uptime(Duration::from_secs(172800)));
1147
1148        state.clear();
1149
1150        assert!(state.system_info().is_none());
1151    }
1152
1153    #[test]
1154    fn system_info_serialization() {
1155        let info = SystemInfo::new()
1156            .with_uptime(Duration::from_secs(172800))
1157            .with_wifi_rssi(-55)
1158            .with_heap(25000);
1159
1160        let json = serde_json::to_string(&info).unwrap();
1161        let deserialized: SystemInfo = serde_json::from_str(&json).unwrap();
1162
1163        assert_eq!(info, deserialized);
1164    }
1165
1166    #[test]
1167    fn device_state_with_system_info_serialization() {
1168        let mut state = DeviceState::new();
1169        state.set_power(1, PowerState::On);
1170        state.set_system_info(
1171            SystemInfo::new()
1172                .with_uptime(Duration::from_secs(172800))
1173                .with_wifi_rssi(-55),
1174        );
1175
1176        let json = serde_json::to_string(&state).unwrap();
1177        let deserialized: DeviceState = serde_json::from_str(&json).unwrap();
1178
1179        assert_eq!(state, deserialized);
1180        assert_eq!(deserialized.uptime(), Some(Duration::from_secs(172800)));
1181    }
1182}