denki 0.2.0

CLI for controlling TP-Link smart home devices — denki (電気) means electricity in Japanese
Documentation
use crate::bulb::LightingEffectState;
use crate::output::terminal_text;
use colored::Colorize;

use crate::commands::KasaContext;
use crate::devices;
use crate::ops;

pub const BUILTIN_EFFECTS: &[&str] = &[
    "Off",
    "Aurora",
    "Bubbling Cauldron",
    "Candy Cane",
    "Christmas",
    "Flicker",
    "Hanukkah",
    "Haunted Mansion",
    "Icicle",
    "Lightning",
    "Ocean",
    "Rainbow",
    "Raindrop",
    "Spring",
    "Valentines",
];

pub fn resolve(name: &str) -> Option<&'static str> {
    let needle = crate::hosts::normalize(name);
    if needle.is_empty() {
        return None;
    }

    let mut exact = BUILTIN_EFFECTS
        .iter()
        .copied()
        .filter(|effect| crate::hosts::normalize(effect) == needle);
    if let Some(effect) = exact.next()
        && exact.next().is_none()
    {
        return Some(effect);
    }

    let mut fuzzy = BUILTIN_EFFECTS
        .iter()
        .copied()
        .filter(|effect| crate::hosts::normalize(effect).contains(&needle));
    if let Some(effect) = fuzzy.next()
        && fuzzy.next().is_none()
    {
        return Some(effect);
    }

    None
}

pub fn print_catalog(current: &LightingEffectState) {
    let state = if current.enable == 1 { "On" } else { "Off" };
    crate::output::println!(
        "Current effect: {} ({state})",
        terminal_text(&current.name).bold()
    );
    crate::output::println!("{}", "Built-in effects:".bold());
    for effect in BUILTIN_EFFECTS {
        if effect.eq_ignore_ascii_case("off") {
            crate::output::println!("  {effect}");
        } else if effect.eq_ignore_ascii_case(&current.name) {
            crate::output::println!("  {effect}  {}", "(selected)".dimmed());
        } else {
            crate::output::println!("  {effect}");
        }
    }
}

pub async fn handle_effects_command(host: &str) -> anyhow::Result<()> {
    let ctx = KasaContext::load(host, "effects").await?;
    devices::can_get_effects(ctx.kind())?;
    let current = ops::lightstrip_current_effect(ctx.ip()).await?;
    crate::output::record(serde_json::json!({"current":current,"available":BUILTIN_EFFECTS}));
    print_catalog(&current);
    Ok(())
}

pub async fn handle_effect_command(host: &str, name: &str) -> anyhow::Result<()> {
    let ctx = KasaContext::load(host, "effect").await?;
    devices::can_get_effects(ctx.kind())?;

    if name.eq_ignore_ascii_case("off") {
        ops::lightstrip_disable_effect(ctx.ip()).await?;
        crate::output::record(serde_json::json!({"effect":"Off"}));
        crate::output::println!("Effect -> {}", "Off".dimmed());
        return Ok(());
    }

    let resolved = resolve(name).ok_or_else(|| {
        anyhow::anyhow!(
            "No light-strip effect named \"{name}\" found. Available effects: {}",
            BUILTIN_EFFECTS.join(", ")
        )
    })?;
    let current = ops::lightstrip_current_effect(ctx.ip()).await?;
    ops::lightstrip_set_effect(ctx.ip(), &current, resolved).await?;
    crate::output::record(serde_json::json!({"effect":resolved}));
    crate::output::println!("Effect -> {}", resolved.bold());
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resolve_matches_exact_and_partial_names() {
        assert_eq!(resolve("Rainbow"), Some("Rainbow"));
        assert_eq!(resolve("bubbl"), Some("Bubbling Cauldron"));
        assert_eq!(resolve("Bubbling Cauldron"), Some("Bubbling Cauldron"));
    }

    #[test]
    fn resolve_rejects_unknown_or_ambiguous_names() {
        assert_eq!(resolve(""), None);
        assert_eq!(resolve("x"), None);
        assert_eq!(resolve("rain"), None);
        assert_eq!(resolve("ca"), None);
    }
}