Auto detect new tapo devices
This commit is contained in:
parent
b748ef36d6
commit
9cc83c43ee
4 changed files with 155 additions and 33 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
14
src/main.rs
14
src/main.rs
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue