Initial commit: tischlerctrl home automation project
This commit is contained in:
383
agents/tapo/src/main.rs
Normal file
383
agents/tapo/src/main.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use log::{error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tapo::{ApiClient, DiscoveryResult};
|
||||
use tokio::time::{interval, sleep};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "tapo-agent")]
|
||||
#[command(about = "Tapo smart plug sensor data collection agent")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
|
||||
/// Path to config file
|
||||
#[arg(short, long, default_value = "config.toml")]
|
||||
config: String,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Initialize configuration file by discovering devices
|
||||
Init {
|
||||
/// Server WebSocket URL
|
||||
#[arg(long)]
|
||||
server: String,
|
||||
|
||||
/// API key for authentication
|
||||
#[arg(long)]
|
||||
key: String,
|
||||
|
||||
/// Tapo account email
|
||||
#[arg(long)]
|
||||
email: String,
|
||||
|
||||
/// Tapo account password
|
||||
#[arg(long)]
|
||||
password: String,
|
||||
|
||||
/// Broadcast address for discovery (default: 192.168.1.255)
|
||||
#[arg(long, default_value = "192.168.1.255")]
|
||||
broadcast: String,
|
||||
|
||||
/// Discovery timeout in seconds
|
||||
#[arg(long, default_value = "10")]
|
||||
timeout: u64,
|
||||
|
||||
/// Output config file path
|
||||
#[arg(short, long, default_value = "config.toml")]
|
||||
output: String,
|
||||
},
|
||||
/// Run the agent (default if no subcommand)
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct Config {
|
||||
server_url: String,
|
||||
api_key: String,
|
||||
poll_interval_secs: u64,
|
||||
devices: Vec<DeviceConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct DeviceConfig {
|
||||
ip: String,
|
||||
name: String,
|
||||
#[serde(rename = "type")]
|
||||
device_type: String,
|
||||
tapo_email: String,
|
||||
tapo_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthMessage {
|
||||
#[serde(rename = "type")]
|
||||
msg_type: String,
|
||||
#[serde(rename = "apiKey")]
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DataMessage {
|
||||
#[serde(rename = "type")]
|
||||
msg_type: String,
|
||||
readings: Vec<Reading>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
struct Reading {
|
||||
device: String,
|
||||
channel: String,
|
||||
value: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerResponse {
|
||||
#[serde(rename = "type")]
|
||||
msg_type: String,
|
||||
success: Option<bool>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn discover_and_create_config(
|
||||
server: String,
|
||||
key: String,
|
||||
email: String,
|
||||
password: String,
|
||||
broadcast: String,
|
||||
timeout: u64,
|
||||
output: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Discovering Tapo devices on {} ({}s timeout)...", broadcast, timeout);
|
||||
|
||||
let api_client = ApiClient::new(&email, &password);
|
||||
let mut discovery = api_client.discover_devices(&broadcast, timeout).await?;
|
||||
|
||||
let mut devices = Vec::new();
|
||||
|
||||
while let Some(discovery_result) = discovery.next().await {
|
||||
if let Ok(device) = discovery_result {
|
||||
match device {
|
||||
DiscoveryResult::Plug { device_info, .. } => {
|
||||
println!(
|
||||
" Found Plug: {} ({}) at {}",
|
||||
device_info.nickname, device_info.model, device_info.ip
|
||||
);
|
||||
devices.push(DeviceConfig {
|
||||
ip: device_info.ip,
|
||||
name: device_info.nickname.replace(" ", "-").to_lowercase(),
|
||||
device_type: "P100".to_string(),
|
||||
tapo_email: email.clone(),
|
||||
tapo_password: password.clone(),
|
||||
});
|
||||
}
|
||||
DiscoveryResult::PlugEnergyMonitoring { device_info, .. } => {
|
||||
println!(
|
||||
" Found Energy Plug: {} ({}) at {}",
|
||||
device_info.nickname, device_info.model, device_info.ip
|
||||
);
|
||||
devices.push(DeviceConfig {
|
||||
ip: device_info.ip,
|
||||
name: device_info.nickname.replace(" ", "-").to_lowercase(),
|
||||
device_type: "P110".to_string(),
|
||||
tapo_email: email.clone(),
|
||||
tapo_password: password.clone(),
|
||||
});
|
||||
}
|
||||
DiscoveryResult::GenericDevice { device_info, .. } => {
|
||||
println!(
|
||||
" Found Unknown Device: {:?} ({}) at {} - skipping",
|
||||
device_info.nickname, device_info.model, device_info.ip
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Light bulbs and other devices - skip for now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if devices.is_empty() {
|
||||
return Err("No plugs discovered. Check your broadcast address and ensure devices are on the same network.".into());
|
||||
}
|
||||
|
||||
println!("\nDiscovered {} plug(s)", devices.len());
|
||||
|
||||
let config = Config {
|
||||
server_url: server,
|
||||
api_key: key,
|
||||
poll_interval_secs: 60,
|
||||
devices,
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string_pretty(&config)?;
|
||||
std::fs::write(&output, &toml_str)?;
|
||||
|
||||
println!("✓ Config written to: {}", output);
|
||||
println!("\nRun the agent with: RUST_LOG=info ./tapo-agent");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn collect_device_data(device: &DeviceConfig) -> Vec<Reading> {
|
||||
let mut readings = Vec::new();
|
||||
let client = ApiClient::new(&device.tapo_email, &device.tapo_password);
|
||||
|
||||
match device.device_type.as_str() {
|
||||
"P110" => {
|
||||
match client.p110(&device.ip).await {
|
||||
Ok(plug) => {
|
||||
if let Ok(info) = plug.get_device_info().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "state".to_string(),
|
||||
value: if info.device_on { 1.0 } else { 0.0 },
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(energy) = plug.get_current_power().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "power".to_string(),
|
||||
value: energy.current_power as f64 / 1000.0,
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(usage) = plug.get_energy_usage().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "energy_today".to_string(),
|
||||
value: usage.today_energy as f64,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Failed to connect to P110 {}: {}", device.name, e),
|
||||
}
|
||||
}
|
||||
"P100" | "P105" => {
|
||||
match client.p100(&device.ip).await {
|
||||
Ok(plug) => {
|
||||
if let Ok(info) = plug.get_device_info().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "state".to_string(),
|
||||
value: if info.device_on { 1.0 } else { 0.0 },
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Failed to connect to P100 {}: {}", device.name, e),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown device type: {}", device.device_type);
|
||||
}
|
||||
}
|
||||
|
||||
readings
|
||||
}
|
||||
|
||||
async fn run_agent(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut reconnect_delay = Duration::from_secs(1);
|
||||
let max_reconnect_delay = Duration::from_secs(60);
|
||||
|
||||
loop {
|
||||
info!("Connecting to {}...", config.server_url);
|
||||
|
||||
match connect_async(&config.server_url).await {
|
||||
Ok((ws_stream, _)) => {
|
||||
info!("Connected to server");
|
||||
reconnect_delay = Duration::from_secs(1);
|
||||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
let auth = AuthMessage {
|
||||
msg_type: "auth".to_string(),
|
||||
api_key: config.api_key.clone(),
|
||||
};
|
||||
let auth_json = serde_json::to_string(&auth)?;
|
||||
write.send(Message::Text(auth_json)).await?;
|
||||
|
||||
let authenticated = if let Some(Ok(msg)) = read.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
let response: ServerResponse = serde_json::from_str(&text)?;
|
||||
if response.msg_type == "auth" && response.success == Some(true) {
|
||||
info!("Authenticated successfully");
|
||||
true
|
||||
} else {
|
||||
error!("Authentication failed: {:?}", response.error);
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !authenticated {
|
||||
sleep(reconnect_delay).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut poll_interval = interval(Duration::from_secs(config.poll_interval_secs));
|
||||
|
||||
loop {
|
||||
poll_interval.tick().await;
|
||||
|
||||
let mut all_readings = Vec::new();
|
||||
for device in &config.devices {
|
||||
let readings = collect_device_data(device).await;
|
||||
all_readings.extend(readings);
|
||||
}
|
||||
|
||||
if !all_readings.is_empty() {
|
||||
info!("Sending {} readings", all_readings.len());
|
||||
let data = DataMessage {
|
||||
msg_type: "data".to_string(),
|
||||
readings: all_readings,
|
||||
};
|
||||
let data_json = serde_json::to_string(&data)?;
|
||||
|
||||
if let Err(e) = write.send(Message::Text(data_json)).await {
|
||||
error!("Failed to send data: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while let Ok(Some(msg)) = tokio::time::timeout(
|
||||
Duration::from_millis(100),
|
||||
read.next(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
match msg {
|
||||
Ok(Message::Ping(data)) => {
|
||||
let _ = write.send(Message::Pong(data)).await;
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
info!("Server closed connection");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Connection failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Reconnecting in {:?}...", reconnect_delay);
|
||||
sleep(reconnect_delay).await;
|
||||
reconnect_delay = std::cmp::min(reconnect_delay * 2, max_reconnect_delay);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env_logger::init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Init {
|
||||
server,
|
||||
key,
|
||||
email,
|
||||
password,
|
||||
broadcast,
|
||||
timeout,
|
||||
output,
|
||||
}) => {
|
||||
discover_and_create_config(server, key, email, password, broadcast, timeout, output).await?;
|
||||
}
|
||||
Some(Commands::Run) | None => {
|
||||
let config_path = &cli.config;
|
||||
|
||||
let config_content = std::fs::read_to_string(config_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read config file {}: {}\n\nCreate config with device discovery:\n ./tapo-agent init --server ws://SERVER:8080 --key YOUR_KEY --email tapo@email.com --password tapopass\n\nOr specify broadcast address:\n ./tapo-agent init --server ws://SERVER:8080 --key YOUR_KEY --email tapo@email.com --password tapopass --broadcast 192.168.0.255",
|
||||
config_path, e
|
||||
)
|
||||
})?;
|
||||
|
||||
let config: Config = toml::from_str(&config_content)
|
||||
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
||||
|
||||
info!("Tapo Agent starting with {} devices", config.devices.len());
|
||||
|
||||
run_agent(config).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user