From 9cc83c43ee782702c142e9f4669d7ed47e4bfff1 Mon Sep 17 00:00:00 2001 From: Jack Mechem Date: Fri, 22 May 2026 11:58:58 -0700 Subject: [PATCH] Auto detect new tapo devices --- Cargo.toml | 1 + src/config.rs | 7 -- src/main.rs | 14 +++- src/routes/power.rs | 166 +++++++++++++++++++++++++++++++++++++------- 4 files changed, 155 insertions(+), 33 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index eac8095..f1734f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ shadow-rs = "1.7.1" sysinfo = "0.38.4" tokio = { version = "1.50.0", features = [ "macros", + "net", "rt-multi-thread", "process", "time", diff --git a/src/config.rs b/src/config.rs index 451d00f..8a2e4ab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,3 @@ -/// (label, ip) pairs for Tapo P115 plugs. Credentials are read from -/// TAPO_USERNAME and TAPO_PASSWORD environment variables at runtime. -pub const TAPO_DEVICES: &[(&str, &str)] = &[ - ("server", "192.168.1.64"), - ("desktop", "192.168.1.85"), -]; - pub const ALLOWED_SERVICES: &[&str] = &[ "syncthing", "caddy", diff --git a/src/main.rs b/src/main.rs index 44012f3..44a5708 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,11 +17,22 @@ async fn main() { let power_history: routes::power::PowerHistory = Arc::new(Mutex::new(routes::power::load_history())); + let device_cache: routes::power::TapoDeviceCache = Arc::new(Mutex::new(vec![])); + + let bg_cache = Arc::clone(&device_cache); + tokio::spawn(async move { + loop { + routes::power::refresh_device_cache(&bg_cache).await; + tokio::time::sleep(tokio::time::Duration::from_secs(600)).await; + } + }); + let bg_history = Arc::clone(&power_history); + let bg_cache2 = Arc::clone(&device_cache); tokio::spawn(async move { loop { tokio::time::sleep(tokio::time::Duration::from_secs(300)).await; - routes::power::record_snapshot(&bg_history).await; + routes::power::record_snapshot(&bg_history, &bg_cache2).await; } }); @@ -64,6 +75,7 @@ async fn main() { .route("/auth/register/finish", post(auth::post_register_finish)) .merge(protected) .with_state(state) + .layer(Extension(device_cache)) .layer(Extension(power_history)); let listener = tokio::net::TcpListener::bind("127.0.0.1:3001") diff --git a/src/routes/power.rs b/src/routes/power.rs index be8483d..c741d5d 100644 --- a/src/routes/power.rs +++ b/src/routes/power.rs @@ -6,9 +6,10 @@ use std::sync::Arc; use tapo::ApiClient; use tokio::sync::Mutex; -use crate::{config, models}; +use crate::models; pub type PowerHistory = Arc>>; +pub type TapoDeviceCache = Arc>>; // (name, ip) const HISTORY_FILE: &str = "/var/lib/server-dash-api/power-history.json"; const MAX_HISTORY_DAYS: i64 = 60; @@ -24,20 +25,119 @@ pub fn load_history() -> Vec { } } -pub async fn record_snapshot(history: &PowerHistory) { +fn get_local_subnet() -> Option { + if let Ok(subnet) = std::env::var("TAPO_SUBNET") { + if !subnet.is_empty() { + return Some(subnet); + } + } + let output = std::process::Command::new("ip") + .args(["route", "get", "1.1.1.1"]) + .output() + .ok()?; + let out = String::from_utf8_lossy(&output.stdout); + let src_ip = out + .split_whitespace() + .skip_while(|&w| w != "src") + .nth(1)? + .to_string(); + let parts: Vec<&str> = src_ip.split('.').collect(); + if parts.len() == 4 { + Some(format!("{}.{}.{}", parts[0], parts[1], parts[2])) + } else { + None + } +} + +pub async fn refresh_device_cache(cache: &TapoDeviceCache) { let username = std::env::var("TAPO_USERNAME").unwrap_or_default(); let password = std::env::var("TAPO_PASSWORD").unwrap_or_default(); if username.is_empty() || password.is_empty() { return; } - let tasks: Vec<_> = config::TAPO_DEVICES + let subnet = match get_local_subnet() { + Some(s) => s, + None => { + eprintln!("Tapo discovery: could not determine local subnet"); + return; + } + }; + + // Probe all 254 hosts concurrently for open port 80 + let probe_tasks: Vec<_> = (1u8..=254) + .map(|i| { + let ip = format!("{subnet}.{i}"); + tokio::spawn(async move { + let addr = format!("{ip}:80"); + let timeout = tokio::time::Duration::from_millis(300); + match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await { + Ok(Ok(_)) => Some(ip), + _ => None, + } + }) + }) + .collect(); + + let mut responsive = Vec::new(); + for task in probe_tasks { + if let Ok(Some(ip)) = task.await { + responsive.push(ip); + } + } + + // Attempt Tapo auth on each responsive host + let auth_tasks: Vec<_> = responsive + .into_iter() + .map(|ip| { + let username = username.clone(); + let password = password.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout( + tokio::time::Duration::from_secs(5), + ApiClient::new(&username, &password).p110(&ip), + ) + .await; + match result { + Ok(Ok(device)) => match device.get_device_info().await { + Ok(info) => Some((info.nickname, ip)), + Err(_) => None, + }, + _ => None, + } + }) + }) + .collect(); + + let mut devices = Vec::new(); + for task in auth_tasks { + if let Ok(Some(pair)) = task.await { + devices.push(pair); + } + } + + if !devices.is_empty() { + let mut guard = cache.lock().await; + *guard = devices; + } +} + +pub async fn record_snapshot(history: &PowerHistory, cache: &TapoDeviceCache) { + let username = std::env::var("TAPO_USERNAME").unwrap_or_default(); + let password = std::env::var("TAPO_PASSWORD").unwrap_or_default(); + if username.is_empty() || password.is_empty() { + return; + } + + let device_list = cache.lock().await.clone(); + + let tasks: Vec<_> = device_list .iter() .map(|(name, ip)| { let username = username.clone(); let password = password.clone(); - let name = name.to_string(); - let ip = ip.to_string(); + let name = name.clone(); + let ip = ip.clone(); tokio::spawn(async move { query_device(&username, &password, &name, &ip).await }) }) .collect(); @@ -118,7 +218,6 @@ async fn query_device( alias: info.nickname, model: info.model, on: info.device_on, - // current_power is in mW, convert to W current_power_w: energy.current_power.unwrap_or(0) as f64 / 1000.0, today_energy_wh: energy.today_energy, month_energy_wh: energy.month_energy, @@ -140,14 +239,7 @@ fn credentials() -> Result<(String, String), (StatusCode, Json Option<&'static str> { - config::TAPO_DEVICES - .iter() - .find(|(n, _)| *n == name) - .map(|(_, ip)| *ip) -} - -pub async fn get_power() -> impl IntoResponse { +pub async fn get_power(Extension(cache): Extension) -> impl IntoResponse { let username = std::env::var("TAPO_USERNAME").unwrap_or_default(); let password = std::env::var("TAPO_PASSWORD").unwrap_or_default(); @@ -159,13 +251,15 @@ pub async fn get_power() -> impl IntoResponse { .into_response(); } - let tasks: Vec<_> = config::TAPO_DEVICES + let device_list = cache.lock().await.clone(); + + let tasks: Vec<_> = device_list .iter() .map(|(name, ip)| { let username = username.clone(); let password = password.clone(); - let name = name.to_string(); - let ip = ip.to_string(); + let name = name.clone(); + let ip = ip.clone(); tokio::spawn(async move { query_device(&username, &password, &name, &ip).await }) }) .collect(); @@ -186,8 +280,17 @@ pub async fn get_power() -> impl IntoResponse { .into_response() } -pub async fn power_on(Path(name): Path) -> impl IntoResponse { - let ip = match resolve_device(&name) { +pub async fn power_on( + Path(name): Path, + Extension(cache): Extension, +) -> impl IntoResponse { + let ip = cache + .lock() + .await + .iter() + .find(|(n, _)| *n == name) + .map(|(_, ip)| ip.clone()); + let ip = match ip { Some(ip) => ip, None => { return models::ActionResponse::err( @@ -200,17 +303,28 @@ pub async fn power_on(Path(name): Path) -> impl IntoResponse { Ok(c) => c, Err(e) => return e, }; - match ApiClient::new(&username, &password).p110(ip).await { + match ApiClient::new(&username, &password).p110(&ip).await { Err(e) => models::ActionResponse::err(StatusCode::BAD_GATEWAY, &format!("connect: {e}")), Ok(device) => match device.on().await { Ok(()) => models::ActionResponse::ok(format!("{name} turned on")), - Err(e) => models::ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + Err(e) => { + models::ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) + } }, } } -pub async fn power_off(Path(name): Path) -> impl IntoResponse { - let ip = match resolve_device(&name) { +pub async fn power_off( + Path(name): Path, + Extension(cache): Extension, +) -> impl IntoResponse { + let ip = cache + .lock() + .await + .iter() + .find(|(n, _)| *n == name) + .map(|(_, ip)| ip.clone()); + let ip = match ip { Some(ip) => ip, None => { return models::ActionResponse::err( @@ -223,11 +337,13 @@ pub async fn power_off(Path(name): Path) -> impl IntoResponse { Ok(c) => c, Err(e) => return e, }; - match ApiClient::new(&username, &password).p110(ip).await { + match ApiClient::new(&username, &password).p110(&ip).await { Err(e) => models::ActionResponse::err(StatusCode::BAD_GATEWAY, &format!("connect: {e}")), Ok(device) => match device.off().await { Ok(()) => models::ActionResponse::ok(format!("{name} turned off")), - Err(e) => models::ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + Err(e) => { + models::ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) + } }, } }