use serde::Deserialize;
use std::sync::OnceLock;
const TOML_SRC: &str = include_str!("../../devices.toml");
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeviceKind {
Bulb,
LightStrip,
Dimmer,
Strip,
Plug,
Tapo,
Unknown(String),
}
impl std::fmt::Display for DeviceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeviceKind::Bulb => write!(f, "bulb"),
DeviceKind::LightStrip => write!(f, "light strip"),
DeviceKind::Dimmer => write!(f, "dimmer"),
DeviceKind::Strip => write!(f, "power strip"),
DeviceKind::Plug => write!(f, "plug"),
DeviceKind::Tapo => write!(f, "tapo"),
DeviceKind::Unknown(t) => write!(f, "unknown ({t})"),
}
}
}
impl<'de> serde::Deserialize<'de> for DeviceKind {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(d)?;
match s.as_str() {
"Bulb" => Ok(DeviceKind::Bulb),
"LightStrip" => Ok(DeviceKind::LightStrip),
"Dimmer" => Ok(DeviceKind::Dimmer),
"Strip" => Ok(DeviceKind::Strip),
"Plug" => Ok(DeviceKind::Plug),
"Tapo" => Ok(DeviceKind::Tapo),
other => Err(serde::de::Error::unknown_variant(
other,
&["Bulb", "LightStrip", "Dimmer", "Strip", "Plug", "Tapo"],
)),
}
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct DeviceEntry {
pub model: String,
pub kind: DeviceKind,
pub verified: bool,
#[serde(default)]
pub protocol: Option<String>,
pub supports: Vec<String>,
#[serde(default)]
pub tapo_auto_supports: Vec<String>,
#[serde(default)]
pub tapo_auto_verified: bool,
}
#[derive(Deserialize)]
struct DevicesFile {
device: Vec<DeviceEntry>,
}
pub fn all() -> &'static [DeviceEntry] {
static CACHE: OnceLock<Vec<DeviceEntry>> = OnceLock::new();
CACHE.get_or_init(|| {
toml::from_str::<DevicesFile>(TOML_SRC)
.expect("devices.toml is malformed — rebuild the binary")
.device
})
}
pub fn lookup(model: &str) -> Option<&'static DeviceEntry> {
let base = model.split('(').next().unwrap_or(model).trim();
all().iter().find(|d| d.model.eq_ignore_ascii_case(base))
}
pub(crate) fn safe_hint_target(alias: &str) -> bool {
!alias.is_empty() && !alias.starts_with('-') && crate::output::is_terminal_safe(alias)
}
pub fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub fn hint_for(feature: &str, alias: &str) -> Option<String> {
if !safe_hint_target(alias) {
return None;
}
let alias = shell_quote(alias);
match feature {
"dim" => Some(format!("denki dim {alias} 80")),
"color_temp" => Some(format!("denki color-temp {alias} 2700")),
"color" => Some(format!("denki color {alias} -H 120 -s 80 -v 100")),
"energy" => Some(format!("denki energy {alias}")),
"effects" => Some(format!("denki effects {alias}")),
"schedules" => Some(format!("denki schedules {alias}")),
"led" => Some(format!("denki led {alias} on")),
"clock" => Some(format!("denki clock {alias}")),
"outlets" => Some(format!("denki outlets {alias}")),
"specs" => Some(format!("denki specs {alias}")),
"presets" => Some(format!("denki presets {alias}")),
_ => None,
}
}
pub fn hints(entry: &DeviceEntry, alias: &str, is_on: bool) -> Vec<String> {
if !safe_hint_target(alias) {
return Vec::new();
}
let action = if is_on { "off" } else { "on" };
let mut out = vec![format!("denki {action} {}", shell_quote(alias))];
for feature in &entry.supports {
if let Some(h) = hint_for(feature, alias) {
out.push(h);
}
}
out
}