feat(tapo): add countdown/schedule support and CLI tool
This commit is contained in:
184
agents/tapo/src/bin/tapo-countdown.rs
Normal file
184
agents/tapo/src/bin/tapo-countdown.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use clap::Parser;
|
||||
use tapo::ApiClient;
|
||||
use tapo::responses::CountdownRulesResult;
|
||||
use tapo::{PlugEnergyMonitoringHandler, PlugHandler};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
// Enum to wrap different device handlers
|
||||
enum DeviceHandler {
|
||||
P100(PlugHandler),
|
||||
P110(PlugEnergyMonitoringHandler),
|
||||
}
|
||||
|
||||
impl DeviceHandler {
|
||||
async fn set_countdown(&self, delay: u64, turn_on: bool) -> Result<(), tapo::Error> {
|
||||
match self {
|
||||
Self::P100(h) => h.set_countdown(delay, turn_on).await,
|
||||
Self::P110(h) => h.set_countdown(delay, turn_on).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_countdown_rules(&self) -> Result<CountdownRulesResult, tapo::Error> {
|
||||
match self {
|
||||
Self::P100(h) => h.get_countdown_rules().await,
|
||||
Self::P110(h) => h.get_countdown_rules().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn on(&self) -> Result<(), tapo::Error> {
|
||||
match self {
|
||||
Self::P100(h) => h.on().await,
|
||||
Self::P110(h) => h.on().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn off(&self) -> Result<(), tapo::Error> {
|
||||
match self {
|
||||
Self::P100(h) => h.off().await,
|
||||
Self::P110(h) => h.off().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "tapo-countdown")]
|
||||
#[command(about = "Set or cancel countdown timer on Tapo smart plug")]
|
||||
struct Cli {
|
||||
/// Device IP address
|
||||
#[arg(short, long)]
|
||||
ip: String,
|
||||
|
||||
/// Tapo account email
|
||||
#[arg(short, long, env = "TAPO_EMAIL")]
|
||||
email: String,
|
||||
|
||||
/// Tapo account password
|
||||
#[arg(short = 'P', long, env = "TAPO_PASSWORD")]
|
||||
password: String,
|
||||
|
||||
/// Device type: P100 or P110 (default: P110)
|
||||
#[arg(short = 't', long, default_value = "P110")]
|
||||
device_type: String,
|
||||
|
||||
/// Delay in seconds (required unless --cancel is used)
|
||||
#[arg(short, long, required_unless_present = "cancel")]
|
||||
delay: Option<u64>,
|
||||
|
||||
/// Action when countdown completes: "on" or "off"
|
||||
#[arg(short, long, default_value = "off")]
|
||||
action: String,
|
||||
|
||||
/// Set immediate state after verifying countdown (safety feature)
|
||||
/// Only works if delay is set. "on" or "off"
|
||||
#[arg(short = 's', long)]
|
||||
set_state: Option<String>,
|
||||
|
||||
/// Cancel any active countdown
|
||||
#[arg(short, long)]
|
||||
cancel: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env_logger::init();
|
||||
let cli = Cli::parse();
|
||||
|
||||
println!("Connecting to {} device at {}...", cli.device_type, cli.ip);
|
||||
|
||||
let client = ApiClient::new(&cli.email, &cli.password);
|
||||
|
||||
// Create the appropriate handler based on device type
|
||||
let plug = match cli.device_type.to_uppercase().as_str() {
|
||||
"P100" | "P105" => DeviceHandler::P100(client.p100(&cli.ip).await?),
|
||||
"P110" | "P115" => DeviceHandler::P110(client.p110(&cli.ip).await?),
|
||||
_ => {
|
||||
eprintln!("Error: device-type must be P100 or P110 (or similar)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if cli.cancel {
|
||||
println!("Canceling countdown...");
|
||||
// Set countdown to disabled by using delay 0
|
||||
plug.set_countdown(0, false).await?;
|
||||
println!("Countdown canceled!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let delay = cli.delay.unwrap();
|
||||
let turn_on = match cli.action.to_lowercase().as_str() {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => {
|
||||
eprintln!("Error: action must be 'on' or 'off'");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"Setting countdown: turn {} in {} seconds",
|
||||
if turn_on { "ON" } else { "OFF" },
|
||||
delay
|
||||
);
|
||||
|
||||
plug.set_countdown(delay, turn_on).await?;
|
||||
println!("Countdown set successfully!");
|
||||
|
||||
// Verify countdown status
|
||||
let mut verified = false;
|
||||
// Retry a few times to ensure device has updated state
|
||||
for _ in 0..3 {
|
||||
match plug.get_countdown_rules().await {
|
||||
Ok(countdown) => {
|
||||
if let Some(rule) = countdown.rules.iter().find(|r| r.enable && r.remain > 0) {
|
||||
let will_turn_on = rule.desired_states.as_ref().and_then(|s| s.on).unwrap_or(false);
|
||||
println!(
|
||||
"Active countdown verified: {} seconds remaining, will turn {}",
|
||||
rule.remain,
|
||||
if will_turn_on { "ON" } else { "OFF" }
|
||||
);
|
||||
|
||||
// Verify that the set rule matches our intention
|
||||
if will_turn_on == turn_on {
|
||||
verified = true;
|
||||
break;
|
||||
} else {
|
||||
eprintln!("Warning: Active countdown action doesn't match requested action!");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Could not verify countdown: {}", e);
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
if verified {
|
||||
if let Some(target_state) = cli.set_state {
|
||||
let set_on = match target_state.to_lowercase().as_str() {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => {
|
||||
eprintln!("Error: set-state must be 'on' or 'off'");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
println!("Safely setting device state to {}...", if set_on { "ON" } else { "OFF" });
|
||||
if set_on {
|
||||
plug.on().await?;
|
||||
} else {
|
||||
plug.off().await?;
|
||||
}
|
||||
println!("Device state updated.");
|
||||
}
|
||||
} else {
|
||||
eprintln!("Verification FAILED or timed out. NOT changing device state for safety.");
|
||||
if cli.set_state.is_some() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use log::{error, info, warn};
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tapo::{ApiClient, DiscoveryResult};
|
||||
@@ -91,8 +91,13 @@ struct DataMessage {
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
struct Reading {
|
||||
device: String,
|
||||
#[serde(skip)]
|
||||
device_type: String,
|
||||
channel: String,
|
||||
value: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
value: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -194,26 +199,34 @@ async fn collect_device_data(device: &DeviceConfig) -> Vec<Reading> {
|
||||
if let Ok(info) = plug.get_device_info().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "state".to_string(),
|
||||
value: if info.device_on { 1.0 } else { 0.0 },
|
||||
value: Some(if info.device_on { 1.0 } else { 0.0 }),
|
||||
data: None,
|
||||
});
|
||||
// Time device has been ON since last state change (seconds)
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "on_time".to_string(),
|
||||
value: info.on_time as f64,
|
||||
value: Some(info.on_time as f64),
|
||||
data: None,
|
||||
});
|
||||
// WiFi signal level (0-3)
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "signal_level".to_string(),
|
||||
value: info.signal_level as f64,
|
||||
value: Some(info.signal_level as f64),
|
||||
data: None,
|
||||
});
|
||||
// WiFi RSSI (dBm, negative value)
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "rssi".to_string(),
|
||||
value: info.rssi as f64,
|
||||
value: Some(info.rssi as f64),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,8 +234,10 @@ async fn collect_device_data(device: &DeviceConfig) -> Vec<Reading> {
|
||||
if let Ok(energy) = plug.get_current_power().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "power".to_string(),
|
||||
value: energy.current_power as f64 / 1000.0,
|
||||
value: Some(energy.current_power as f64 / 1000.0),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,71 +245,73 @@ async fn collect_device_data(device: &DeviceConfig) -> Vec<Reading> {
|
||||
// Today's energy in Wh
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "energy_today".to_string(),
|
||||
value: usage.today_energy as f64,
|
||||
value: Some(usage.today_energy as f64),
|
||||
data: None,
|
||||
});
|
||||
// Today's runtime in minutes
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "runtime_today".to_string(),
|
||||
value: usage.today_runtime as f64,
|
||||
value: Some(usage.today_runtime as f64),
|
||||
data: None,
|
||||
});
|
||||
// This month's energy in Wh
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "energy_month".to_string(),
|
||||
value: usage.month_energy as f64,
|
||||
value: Some(usage.month_energy as f64),
|
||||
data: None,
|
||||
});
|
||||
// This month's runtime in minutes
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "runtime_month".to_string(),
|
||||
value: usage.month_runtime as f64,
|
||||
value: Some(usage.month_runtime as f64),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Countdown timer status
|
||||
if let Ok(countdown) = plug.get_countdown_rules().await {
|
||||
let active_countdown = countdown.rules.iter().find(|r| r.enable);
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "countdown_active".to_string(),
|
||||
value: if active_countdown.is_some() { 1.0 } else { 0.0 },
|
||||
});
|
||||
if let Some(rule) = active_countdown {
|
||||
// Countdown timer - return full data or null if none
|
||||
match plug.get_countdown_rules().await {
|
||||
Ok(countdown) => {
|
||||
let active = countdown.rules.iter().find(|r| r.enable);
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "countdown_remain".to_string(),
|
||||
value: rule.remain as f64,
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "countdown".to_string(),
|
||||
value: None,
|
||||
data: Some(if let Some(rule) = active {
|
||||
serde_json::json!({
|
||||
"remain": rule.remain,
|
||||
"action": rule.desired_states.as_ref()
|
||||
.and_then(|s| s.on)
|
||||
.map(|on| if on { "on" } else { "off" })
|
||||
})
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
}),
|
||||
});
|
||||
}
|
||||
Err(e) => debug!("get_countdown_rules failed for {}: {}", device.name, e),
|
||||
}
|
||||
|
||||
// Schedule rules count
|
||||
if let Ok(schedules) = plug.get_schedule_rules().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "schedule_count".to_string(),
|
||||
value: schedules.rules.len() as f64,
|
||||
});
|
||||
// Count active schedules
|
||||
let active_count = schedules.rules.iter().filter(|r| r.enable).count();
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "schedule_active_count".to_string(),
|
||||
value: active_count as f64,
|
||||
});
|
||||
}
|
||||
|
||||
// Next scheduled event
|
||||
if let Ok(next) = plug.get_next_event().await {
|
||||
if let Some(ts) = next.timestamp {
|
||||
// Schedule rules - return full schedule list
|
||||
match plug.get_schedule_rules().await {
|
||||
Ok(schedules) => {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
channel: "next_event_time".to_string(),
|
||||
value: ts as f64,
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "schedules".to_string(),
|
||||
value: None,
|
||||
data: Some(serde_json::to_value(&schedules.rules).unwrap_or_default()),
|
||||
});
|
||||
}
|
||||
Err(e) => debug!("get_schedule_rules failed for {}: {}", device.name, e),
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Failed to connect to P110 {}: {}", device.name, e),
|
||||
@@ -306,9 +323,71 @@ async fn collect_device_data(device: &DeviceConfig) -> Vec<Reading> {
|
||||
if let Ok(info) = plug.get_device_info().await {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "state".to_string(),
|
||||
value: if info.device_on { 1.0 } else { 0.0 },
|
||||
value: Some(if info.device_on { 1.0 } else { 0.0 }),
|
||||
data: None,
|
||||
});
|
||||
// Time device has been ON since last state change (seconds)
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "on_time".to_string(),
|
||||
value: Some(info.on_time as f64),
|
||||
data: None,
|
||||
});
|
||||
// WiFi signal level (0-3)
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "signal_level".to_string(),
|
||||
value: Some(info.signal_level as f64),
|
||||
data: None,
|
||||
});
|
||||
// WiFi RSSI (dBm, negative value)
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "rssi".to_string(),
|
||||
value: Some(info.rssi as f64),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Countdown rules
|
||||
match plug.get_countdown_rules().await {
|
||||
Ok(countdown) => {
|
||||
let active = countdown.rules.iter().find(|r| r.enable);
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "countdown".to_string(),
|
||||
value: None,
|
||||
data: Some(if let Some(rule) = active {
|
||||
serde_json::json!({
|
||||
"remain": rule.remain,
|
||||
"action": if rule.desired_states.as_ref().and_then(|s| s.on).unwrap_or(false) { "on" } else { "off" }
|
||||
})
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
}),
|
||||
});
|
||||
}
|
||||
Err(e) => debug!("get_countdown_rules failed for {}: {}", device.name, e),
|
||||
}
|
||||
|
||||
// Schedule rules
|
||||
match plug.get_schedule_rules().await {
|
||||
Ok(schedules) => {
|
||||
readings.push(Reading {
|
||||
device: device.name.clone(),
|
||||
device_type: device.device_type.clone(),
|
||||
channel: "schedules".to_string(),
|
||||
value: None,
|
||||
data: Some(serde_json::to_value(&schedules.rules).unwrap_or_default()),
|
||||
});
|
||||
}
|
||||
Err(e) => debug!("get_schedule_rules failed for {}: {}", device.name, e),
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Failed to connect to P100 {}: {}", device.name, e),
|
||||
@@ -344,9 +423,18 @@ async fn run_agent(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
if !all_readings.is_empty() {
|
||||
info!("Collected {} readings from devices", all_readings.len());
|
||||
// Log readings even if not connected
|
||||
// Group readings by device for cleaner output
|
||||
let mut current_device = String::new();
|
||||
for reading in &all_readings {
|
||||
info!(" {} {} = {}", reading.device, reading.channel, reading.value);
|
||||
if reading.device != current_device {
|
||||
current_device = reading.device.clone();
|
||||
info!("Device: {} (name: {})", reading.device_type, current_device);
|
||||
}
|
||||
if let Some(val) = reading.value {
|
||||
info!(" {} = {}", reading.channel, val);
|
||||
} else if let Some(ref data) = reading.data {
|
||||
info!(" {} = {}", reading.channel, data);
|
||||
}
|
||||
}
|
||||
// Try to send to connection task, drop if channel full
|
||||
let _ = tx.try_send(all_readings);
|
||||
|
||||
Reference in New Issue
Block a user