denki 0.2.0

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

use crate::creds;
use crate::devices::{self, DeviceKind};
use crate::klap;
use crate::ops;
use crate::resolve::{Resolved, require_kasa, resolve, resolve_outlet};
use crate::strip;

pub(crate) async fn tapo_session(ip: &str) -> Result<klap::KlapSession> {
    let (user, pass) = creds::load()?;
    klap::handshake(ip, &user, &pass).await
}

pub(crate) struct KasaContext {
    resolved: Resolved,
    json: serde_json::Value,
    kind: DeviceKind,
}

impl KasaContext {
    pub(crate) async fn load(host: &str, cmd: &str) -> Result<Self> {
        let resolved = resolve(host).await?;
        Self::from_resolved(&resolved, cmd).await
    }

    pub(crate) async fn from_resolved(resolved: &Resolved, cmd: &str) -> Result<Self> {
        require_kasa(resolved, cmd)?;
        let json = ops::sysinfo(&resolved.ip).await?;
        let kind = devices::detect_kind(&json);
        Ok(Self {
            resolved: Resolved {
                ip: resolved.ip.clone(),
                protocol: resolved.protocol.clone(),
                saved_name: resolved.saved_name.clone(),
            },
            json,
            kind,
        })
    }

    pub(crate) fn ip(&self) -> &str {
        &self.resolved.ip
    }

    pub(crate) fn kind(&self) -> &DeviceKind {
        &self.kind
    }

    pub(crate) fn json(&self) -> &serde_json::Value {
        &self.json
    }

    pub(crate) fn strip_outlet(&self, outlet: u8) -> Result<(String, String, bool)> {
        let s = strip::parse(&self.json)
            .ok_or_else(|| anyhow::anyhow!("{} does not appear to be a power strip", self.ip()))?;
        let child = resolve_outlet(&s, outlet)?;
        let state = self
            .json
            .pointer("/system/get_sysinfo/children")
            .and_then(|v| v.get(usize::from(outlet - 1)))
            .and_then(|v| v.get("state"));
        Ok((
            child.id.clone(),
            child.alias.clone(),
            ops::binary_state(state)?,
        ))
    }

    pub(crate) fn strip_energy_outlet(&self, outlet: u8) -> Result<(String, String)> {
        let s = strip::parse(&self.json)
            .ok_or_else(|| anyhow::anyhow!("{} does not appear to be a power strip", self.ip()))?;
        if !s.has_energy_monitoring() {
            anyhow::bail!("{} ({}) does not have energy monitoring", s.alias, s.model);
        }
        let child = resolve_outlet(&s, outlet)?;
        Ok((child.id.clone(), child.alias.clone()))
    }
}

pub(crate) struct StripOutletTarget {
    pub(crate) child_id: String,
    pub(crate) child_alias: String,
    pub(crate) was_on: bool,
}

pub(crate) fn power_state_label(is_on: bool) -> colored::ColoredString {
    if is_on {
        "on".green().bold()
    } else {
        "off".dimmed()
    }
}

pub(crate) fn print_power_state(ip: &str, is_on: bool) {
    crate::output::record(serde_json::json!({"ip":ip,"power_on":is_on}));
    crate::output::println!("{} {}", terminal_text(ip), power_state_label(is_on));
}

pub(crate) fn print_outlet_power_state(outlet: u8, alias: &str, is_on: bool) {
    crate::output::record(serde_json::json!({"outlet":outlet,"alias":alias,"power_on":is_on}));
    crate::output::println!(
        "Outlet {} ({}) {}",
        outlet,
        terminal_text(alias),
        power_state_label(is_on)
    );
}

pub(crate) fn print_outlet_toggle_state(outlet: u8, alias: &str, now_on: bool) {
    crate::output::record(serde_json::json!({"outlet":outlet,"alias":alias,"power_on":now_on}));
    crate::output::println!(
        "Outlet {outlet} ({}) -> {}",
        terminal_text(alias),
        power_state_label(now_on)
    );
}

pub(crate) async fn resolve_power_target(
    host: &str,
    outlet: Option<u8>,
    cmd: &str,
) -> Result<(Resolved, Option<StripOutletTarget>)> {
    let r = resolve(host).await?;
    let target = if let Some(outlet_num) = outlet {
        let ctx = KasaContext::from_resolved(&r, cmd).await?;
        let (child_id, child_alias, was_on) = ctx.strip_outlet(outlet_num)?;
        Some(StripOutletTarget {
            child_id,
            child_alias,
            was_on,
        })
    } else {
        None
    };
    Ok((r, target))
}

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

    fn strip_ctx(json: serde_json::Value) -> KasaContext {
        KasaContext {
            resolved: Resolved {
                ip: "1.2.3.4".to_string(),
                protocol: hosts::Protocol::Kasa,
                saved_name: None,
            },
            kind: DeviceKind::Strip,
            json,
        }
    }

    #[test]
    fn strip_outlet_succeeds_on_ene_strip() {
        let ctx = strip_ctx(test_support::strip_sysinfo(
            "Test Strip",
            "HS300(US)",
            "TIM:ENE",
            vec![
                test_support::strip_child("ID00", 0, "Outlet 1", 0),
                test_support::strip_child("ID01", 0, "Outlet 2", 0),
                test_support::strip_child("ID02", 0, "Outlet 3", 0),
            ],
        ));
        let (id, alias, on) = ctx.strip_outlet(2).unwrap();
        assert_eq!(id, "ID01");
        assert_eq!(alias, "Outlet 2");
        assert!(!on);
    }

    #[test]
    fn strip_outlet_fails_on_non_strip_json() {
        let ctx = strip_ctx(serde_json::json!({}));
        let err = ctx.strip_outlet(1).unwrap_err();
        assert!(err.to_string().contains("power strip"), "{err}");
    }

    #[test]
    fn strip_energy_outlet_fails_without_ene() {
        let ctx = strip_ctx(test_support::strip_sysinfo(
            "Basic Strip",
            "KP303(US)",
            "TIM",
            vec![test_support::strip_child("A1", 0, "Outlet 1", 0)],
        ));
        let err = ctx.strip_energy_outlet(1).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("energy monitoring"), "{msg}");
        assert!(msg.contains("KP303"), "{msg}");
    }

    #[test]
    fn strip_outlet_fails_on_out_of_range_outlet() {
        let ctx = strip_ctx(test_support::strip_sysinfo(
            "Test Strip",
            "HS300(US)",
            "TIM:ENE",
            vec![
                test_support::strip_child("ID00", 0, "Outlet 1", 0),
                test_support::strip_child("ID01", 0, "Outlet 2", 0),
            ],
        ));
        let err = ctx.strip_outlet(5).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("outlet 5"), "{msg}");
        assert!(msg.contains("2 outlets"), "{msg}");
    }
}