Auto detect new tapo devices

This commit is contained in:
Jack Mechem 2026-05-22 11:58:58 -07:00
parent b748ef36d6
commit 9cc83c43ee
4 changed files with 155 additions and 33 deletions

View file

@ -23,6 +23,7 @@ shadow-rs = "1.7.1"
sysinfo = "0.38.4" sysinfo = "0.38.4"
tokio = { version = "1.50.0", features = [ tokio = { version = "1.50.0", features = [
"macros", "macros",
"net",
"rt-multi-thread", "rt-multi-thread",
"process", "process",
"time", "time",

View file

@ -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] = &[ pub const ALLOWED_SERVICES: &[&str] = &[
"syncthing", "syncthing",
"caddy", "caddy",

View file

@ -17,11 +17,22 @@ async fn main() {
let power_history: routes::power::PowerHistory = let power_history: routes::power::PowerHistory =
Arc::new(Mutex::new(routes::power::load_history())); 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_history = Arc::clone(&power_history);
let bg_cache2 = Arc::clone(&device_cache);
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
tokio::time::sleep(tokio::time::Duration::from_secs(300)).await; 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)) .route("/auth/register/finish", post(auth::post_register_finish))
.merge(protected) .merge(protected)
.with_state(state) .with_state(state)
.layer(Extension(device_cache))
.layer(Extension(power_history)); .layer(Extension(power_history));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001") let listener = tokio::net::TcpListener::bind("127.0.0.1:3001")

View file

@ -6,9 +6,10 @@ use std::sync::Arc;
use tapo::ApiClient; use tapo::ApiClient;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{config, models}; use crate::models;
pub type PowerHistory = Arc<Mutex<Vec<models::PowerHistoryEntry>>>; pub type PowerHistory = Arc<Mutex<Vec<models::PowerHistoryEntry>>>;
pub type TapoDeviceCache = Arc<Mutex<Vec<(String, String)>>>; // (name, ip)
const HISTORY_FILE: &str = "/var/lib/server-dash-api/power-history.json"; const HISTORY_FILE: &str = "/var/lib/server-dash-api/power-history.json";
const MAX_HISTORY_DAYS: i64 = 60; const MAX_HISTORY_DAYS: i64 = 60;
@ -24,20 +25,119 @@ pub fn load_history() -> Vec<models::PowerHistoryEntry> {
} }
} }
pub async fn record_snapshot(history: &PowerHistory) { fn get_local_subnet() -> Option<String> {
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 username = std::env::var("TAPO_USERNAME").unwrap_or_default();
let password = std::env::var("TAPO_PASSWORD").unwrap_or_default(); let password = std::env::var("TAPO_PASSWORD").unwrap_or_default();
if username.is_empty() || password.is_empty() { if username.is_empty() || password.is_empty() {
return; 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() .iter()
.map(|(name, ip)| { .map(|(name, ip)| {
let username = username.clone(); let username = username.clone();
let password = password.clone(); let password = password.clone();
let name = name.to_string(); let name = name.clone();
let ip = ip.to_string(); let ip = ip.clone();
tokio::spawn(async move { query_device(&username, &password, &name, &ip).await }) tokio::spawn(async move { query_device(&username, &password, &name, &ip).await })
}) })
.collect(); .collect();
@ -118,7 +218,6 @@ async fn query_device(
alias: info.nickname, alias: info.nickname,
model: info.model, model: info.model,
on: info.device_on, 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, current_power_w: energy.current_power.unwrap_or(0) as f64 / 1000.0,
today_energy_wh: energy.today_energy, today_energy_wh: energy.today_energy,
month_energy_wh: energy.month_energy, month_energy_wh: energy.month_energy,
@ -140,14 +239,7 @@ fn credentials() -> Result<(String, String), (StatusCode, Json<models::ActionRes
} }
} }
fn resolve_device(name: &str) -> Option<&'static str> { pub async fn get_power(Extension(cache): Extension<TapoDeviceCache>) -> impl IntoResponse {
config::TAPO_DEVICES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, ip)| *ip)
}
pub async fn get_power() -> impl IntoResponse {
let username = std::env::var("TAPO_USERNAME").unwrap_or_default(); let username = std::env::var("TAPO_USERNAME").unwrap_or_default();
let password = std::env::var("TAPO_PASSWORD").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(); .into_response();
} }
let tasks: Vec<_> = config::TAPO_DEVICES let device_list = cache.lock().await.clone();
let tasks: Vec<_> = device_list
.iter() .iter()
.map(|(name, ip)| { .map(|(name, ip)| {
let username = username.clone(); let username = username.clone();
let password = password.clone(); let password = password.clone();
let name = name.to_string(); let name = name.clone();
let ip = ip.to_string(); let ip = ip.clone();
tokio::spawn(async move { query_device(&username, &password, &name, &ip).await }) tokio::spawn(async move { query_device(&username, &password, &name, &ip).await })
}) })
.collect(); .collect();
@ -186,8 +280,17 @@ pub async fn get_power() -> impl IntoResponse {
.into_response() .into_response()
} }
pub async fn power_on(Path(name): Path<String>) -> impl IntoResponse { pub async fn power_on(
let ip = match resolve_device(&name) { Path(name): Path<String>,
Extension(cache): Extension<TapoDeviceCache>,
) -> impl IntoResponse {
let ip = cache
.lock()
.await
.iter()
.find(|(n, _)| *n == name)
.map(|(_, ip)| ip.clone());
let ip = match ip {
Some(ip) => ip, Some(ip) => ip,
None => { None => {
return models::ActionResponse::err( return models::ActionResponse::err(
@ -200,17 +303,28 @@ pub async fn power_on(Path(name): Path<String>) -> impl IntoResponse {
Ok(c) => c, Ok(c) => c,
Err(e) => return e, 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}")), Err(e) => models::ActionResponse::err(StatusCode::BAD_GATEWAY, &format!("connect: {e}")),
Ok(device) => match device.on().await { Ok(device) => match device.on().await {
Ok(()) => models::ActionResponse::ok(format!("{name} turned on")), 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<String>) -> impl IntoResponse { pub async fn power_off(
let ip = match resolve_device(&name) { Path(name): Path<String>,
Extension(cache): Extension<TapoDeviceCache>,
) -> impl IntoResponse {
let ip = cache
.lock()
.await
.iter()
.find(|(n, _)| *n == name)
.map(|(_, ip)| ip.clone());
let ip = match ip {
Some(ip) => ip, Some(ip) => ip,
None => { None => {
return models::ActionResponse::err( return models::ActionResponse::err(
@ -223,11 +337,13 @@ pub async fn power_off(Path(name): Path<String>) -> impl IntoResponse {
Ok(c) => c, Ok(c) => c,
Err(e) => return e, 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}")), Err(e) => models::ActionResponse::err(StatusCode::BAD_GATEWAY, &format!("connect: {e}")),
Ok(device) => match device.off().await { Ok(device) => match device.off().await {
Ok(()) => models::ActionResponse::ok(format!("{name} turned off")), 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())
}
}, },
} }
} }