use crate::response::StatusResponse;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct Capabilities {
power_channels: u8,
dimmer_control: bool,
color_temperature_control: bool,
rgb_control: bool,
energy_monitoring: bool,
}
impl Capabilities {
#[must_use]
pub const fn power_channels(&self) -> u8 {
self.power_channels
}
#[must_use]
pub const fn supports_dimmer_control(&self) -> bool {
self.dimmer_control
}
#[must_use]
pub const fn supports_color_temperature_control(&self) -> bool {
self.color_temperature_control
}
#[must_use]
pub const fn supports_rgb_control(&self) -> bool {
self.rgb_control
}
#[must_use]
pub const fn supports_energy_monitoring(&self) -> bool {
self.energy_monitoring
}
pub fn features(&self) -> impl Iterator<Item = &'static str> {
[
self.dimmer_control.then_some("dimmer_control"),
self.color_temperature_control
.then_some("color_temperature_control"),
self.rgb_control.then_some("rgb_control"),
self.energy_monitoring.then_some("energy_monitoring"),
]
.into_iter()
.flatten()
}
}
impl Default for Capabilities {
fn default() -> Self {
Self {
power_channels: 1,
dimmer_control: false,
color_temperature_control: false,
rgb_control: false,
energy_monitoring: false,
}
}
}
impl Capabilities {
#[must_use]
pub const fn basic() -> Self {
Self {
power_channels: 1,
dimmer_control: false,
color_temperature_control: false,
rgb_control: false,
energy_monitoring: false,
}
}
#[must_use]
pub const fn neo_coolcam() -> Self {
Self {
power_channels: 1,
dimmer_control: false,
color_temperature_control: false,
rgb_control: false,
energy_monitoring: true,
}
}
#[must_use]
pub const fn rgb_light() -> Self {
Self {
power_channels: 1,
dimmer_control: true,
color_temperature_control: false,
rgb_control: true,
energy_monitoring: false,
}
}
#[must_use]
pub const fn rgbcct_light() -> Self {
Self {
power_channels: 1,
dimmer_control: true,
color_temperature_control: true,
rgb_control: true,
energy_monitoring: false,
}
}
#[must_use]
pub const fn cct_light() -> Self {
Self {
power_channels: 1,
dimmer_control: true,
color_temperature_control: true,
rgb_control: false,
energy_monitoring: false,
}
}
#[must_use]
pub fn from_status(status: &StatusResponse) -> Self {
let mut caps = Self::default();
if let Some(ref device) = status.status {
if device.module == 49 {
caps.energy_monitoring = true;
}
if !device.friendly_name.is_empty() {
#[allow(clippy::cast_possible_truncation)]
let count = device.friendly_name.len().min(8) as u8;
caps.power_channels = count;
}
}
if let Some(ref state) = status.sensor_status {
if state.get("Dimmer").is_some() {
caps.dimmer_control = true;
}
if state.get("CT").is_some() {
caps.color_temperature_control = true;
}
if state.get("HSBColor").is_some() {
caps.rgb_control = true;
}
if state.get("ENERGY").is_some() {
caps.energy_monitoring = true;
}
}
if status
.sensors
.as_ref()
.is_some_and(|s| s.get("ENERGY").is_some())
{
caps.energy_monitoring = true;
}
caps
}
#[must_use]
pub const fn is_light(&self) -> bool {
self.dimmer_control || self.color_temperature_control || self.rgb_control
}
#[must_use]
pub const fn has_energy_monitoring(&self) -> bool {
self.energy_monitoring
}
#[must_use]
pub const fn is_multi_relay(&self) -> bool {
self.power_channels > 1
}
}
#[derive(Debug, Default)]
pub struct CapabilitiesBuilder {
inner: Capabilities,
}
impl CapabilitiesBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn power_channels(mut self, count: u8) -> Self {
self.inner.power_channels = count.clamp(1, 8);
self
}
#[must_use]
pub fn with_dimmer_control(mut self) -> Self {
self.inner.dimmer_control = true;
self
}
#[must_use]
pub fn with_color_temperature_control(mut self) -> Self {
self.inner.color_temperature_control = true;
self
}
#[must_use]
pub fn with_rgb_control(mut self) -> Self {
self.inner.rgb_control = true;
self
}
#[must_use]
pub fn with_energy_monitoring(mut self) -> Self {
self.inner.energy_monitoring = true;
self
}
#[must_use]
pub fn build(self) -> Capabilities {
self.inner
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::response::StatusResponse;
#[test]
fn default_capabilities() {
let caps = Capabilities::default();
assert_eq!(caps.power_channels, 1);
assert!(!caps.dimmer_control);
assert!(!caps.color_temperature_control);
assert!(!caps.rgb_control);
assert!(!caps.energy_monitoring);
}
#[test]
fn neo_coolcam_capabilities() {
let caps = Capabilities::neo_coolcam();
assert_eq!(caps.power_channels, 1);
assert!(!caps.dimmer_control);
assert!(caps.energy_monitoring);
}
#[test]
fn rgbcct_light_capabilities() {
let caps = Capabilities::rgbcct_light();
assert!(caps.dimmer_control);
assert!(caps.color_temperature_control);
assert!(caps.rgb_control);
assert!(!caps.energy_monitoring);
assert!(caps.is_light());
}
#[test]
fn builder_pattern() {
let caps = CapabilitiesBuilder::new()
.power_channels(2)
.with_dimmer_control()
.with_energy_monitoring()
.build();
assert_eq!(caps.power_channels, 2);
assert!(caps.dimmer_control);
assert!(caps.energy_monitoring);
assert!(!caps.rgb_control);
}
#[test]
fn capability_checks() {
let light = Capabilities::rgb_light();
assert!(light.is_light());
assert!(!light.has_energy_monitoring());
assert!(!light.is_multi_relay());
let plug = Capabilities::neo_coolcam();
assert!(!plug.is_light());
assert!(plug.has_energy_monitoring());
let multi = CapabilitiesBuilder::new().power_channels(4).build();
assert!(multi.is_multi_relay());
}
#[test]
fn features_iterator() {
let basic = Capabilities::basic();
assert_eq!(basic.features().count(), 0);
let rgb = Capabilities::rgb_light();
let features: Vec<_> = rgb.features().collect();
assert_eq!(features.len(), 2);
assert!(features.contains(&"dimmer_control"));
assert!(features.contains(&"rgb_control"));
let full = CapabilitiesBuilder::new()
.with_dimmer_control()
.with_color_temperature_control()
.with_rgb_control()
.with_energy_monitoring()
.build();
let all_features: Vec<_> = full.features().collect();
assert_eq!(all_features.len(), 4);
assert!(all_features.contains(&"dimmer_control"));
assert!(all_features.contains(&"color_temperature_control"));
assert!(all_features.contains(&"rgb_control"));
assert!(all_features.contains(&"energy_monitoring"));
}
#[test]
fn from_status_detects_neo_coolcam_by_module_id() {
let json = r#"{
"Status": {
"Module": 49,
"DeviceName": "Neo Coolcam Plug",
"FriendlyName": ["Plug"],
"Topic": "tasmota_plug",
"Power": 1
}
}"#;
let status: StatusResponse = serde_json::from_str(json).unwrap();
let caps = Capabilities::from_status(&status);
assert!(
caps.energy_monitoring,
"Neo Coolcam (Module 49) should have energy monitoring"
);
assert_eq!(caps.power_channels, 1);
}
#[test]
fn from_status_detects_multi_relay_from_friendly_names() {
let json = r#"{
"Status": {
"Module": 18,
"DeviceName": "4CH Pro",
"FriendlyName": ["Relay 1", "Relay 2", "Relay 3", "Relay 4"],
"Topic": "tasmota_4ch"
}
}"#;
let status: StatusResponse = serde_json::from_str(json).unwrap();
let caps = Capabilities::from_status(&status);
assert_eq!(caps.power_channels, 4);
assert!(caps.is_multi_relay());
}
#[test]
fn from_status_detects_energy_from_status_sns() {
let json = r#"{
"Status": {
"Module": 18,
"DeviceName": "Smart Plug",
"FriendlyName": ["Plug"]
},
"StatusSTS": {
"POWER": "ON",
"ENERGY": {
"Total": 3.185,
"Yesterday": 3.058,
"Today": 0.127,
"Power": 45,
"Factor": 0.95,
"Voltage": 230,
"Current": 0.195
}
}
}"#;
let status: StatusResponse = serde_json::from_str(json).unwrap();
let caps = Capabilities::from_status(&status);
assert!(
caps.energy_monitoring,
"Device with ENERGY in StatusSTS should have energy monitoring"
);
}
#[test]
fn from_status_detects_dimmer_capability() {
let json = r#"{
"Status": {
"Module": 18,
"DeviceName": "Dimmable Light",
"FriendlyName": ["Light"]
},
"StatusSTS": {
"Time": "2024-01-15T12:00:00",
"POWER": "ON",
"Dimmer": 75
}
}"#;
let status: StatusResponse = serde_json::from_str(json).unwrap();
let caps = Capabilities::from_status(&status);
assert!(
caps.dimmer_control,
"Device with Dimmer in StatusSTS should have dimmer capability"
);
assert!(caps.is_light());
}
#[test]
fn from_status_detects_color_temperature_capability() {
let json = r#"{
"Status": {
"Module": 18,
"DeviceName": "CCT Bulb",
"FriendlyName": ["Bulb"]
},
"StatusSTS": {
"Time": "2024-01-15T12:00:00",
"POWER": "ON",
"CT": 250
}
}"#;
let status: StatusResponse = serde_json::from_str(json).unwrap();
let caps = Capabilities::from_status(&status);
assert!(
caps.color_temperature_control,
"Device with CT in StatusSTS should have color temperature capability"
);
assert!(caps.is_light());
}
#[test]
fn from_status_detects_rgb_capability() {
let json = r#"{
"Status": {
"Module": 18,
"DeviceName": "RGB Bulb",
"FriendlyName": ["Bulb"]
},
"StatusSTS": {
"Time": "2024-01-15T12:00:00",
"POWER": "ON",
"HSBColor": "180,100,100"
}
}"#;
let status: StatusResponse = serde_json::from_str(json).unwrap();
let caps = Capabilities::from_status(&status);
assert!(
caps.rgb_control,
"Device with HSBColor in StatusSTS should have RGB capability"
);
assert!(caps.is_light());
}
#[test]
fn from_status_detects_full_rgbcct_light() {
let json = r#"{
"Status": {
"Module": 18,
"DeviceName": "RGBCCT Bulb",
"FriendlyName": ["Smart Bulb"]
},
"StatusSTS": {
"Time": "2024-01-15T12:00:00",
"POWER": "ON",
"Dimmer": 100,
"Color": "255,128,64,200,100",
"HSBColor": "20,75,100",
"White": 78,
"CT": 300,
"Channel": [100, 50, 25, 78, 39]
}
}"#;
let status: StatusResponse = serde_json::from_str(json).unwrap();
let caps = Capabilities::from_status(&status);
assert!(caps.dimmer_control, "RGBCCT light should have dimmer");
assert!(
caps.color_temperature_control,
"RGBCCT light should have color temperature"
);
assert!(caps.rgb_control, "RGBCCT light should have RGB");
assert!(caps.is_light());
}
#[test]
fn from_status_basic_switch_no_special_capabilities() {
let json = r#"{
"Status": {
"Module": 1,
"DeviceName": "Basic Switch",
"FriendlyName": ["Switch"],
"Topic": "tasmota_switch",
"Power": 0
}
}"#;
let status: StatusResponse = serde_json::from_str(json).unwrap();
let caps = Capabilities::from_status(&status);
assert_eq!(caps.power_channels, 1);
assert!(!caps.dimmer_control);
assert!(!caps.color_temperature_control);
assert!(!caps.rgb_control);
assert!(!caps.energy_monitoring);
assert!(!caps.is_light());
}
#[test]
fn from_status_power_channels_clamped_to_8() {
let json = r#"{
"Status": {
"Module": 18,
"DeviceName": "Many Relays",
"FriendlyName": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10"]
}
}"#;
let status: StatusResponse = serde_json::from_str(json).unwrap();
let caps = Capabilities::from_status(&status);
assert_eq!(
caps.power_channels, 8,
"Power channels should be clamped to max 8"
);
}
#[test]
fn from_status_empty_response() {
let json = r#"{}"#;
let status: StatusResponse = serde_json::from_str(json).unwrap();
let caps = Capabilities::from_status(&status);
assert_eq!(caps.power_channels, 1);
assert!(!caps.dimmer_control);
assert!(!caps.color_temperature_control);
assert!(!caps.rgb_control);
assert!(!caps.energy_monitoring);
}
#[test]
fn builder_with_color_temperature_control() {
let caps = CapabilitiesBuilder::new()
.with_color_temperature_control()
.build();
assert!(caps.color_temperature_control);
assert!(caps.is_light());
}
#[test]
fn builder_with_rgb_control() {
let caps = CapabilitiesBuilder::new().with_rgb_control().build();
assert!(caps.rgb_control);
assert!(caps.is_light());
}
}