feat(tapo): add countdown/schedule support and CLI tool

This commit is contained in:
seb
2025-12-24 06:53:49 +01:00
parent 028763bdb2
commit 853a67c73a
12 changed files with 513 additions and 124 deletions

View File

@@ -14,7 +14,7 @@ serde_json = "1"
toml = "0.8" toml = "0.8"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive", "env"] }
# Add reqwest with rustls to override tapo's default # Add reqwest with rustls to override tapo's default
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }

View File

@@ -124,14 +124,18 @@ echo -e "${GREEN}Building for local/native target...${NC}"
HOST_TARGET=$(rustc -vV | grep host | cut -d' ' -f2) HOST_TARGET=$(rustc -vV | grep host | cut -d' ' -f2)
# Use separate target dir for local builds to avoid GLIBC conflicts with cross builds # Use separate target dir for local builds to avoid GLIBC conflicts with cross builds
CARGO_TARGET_DIR=target-local cargo build --release -j $(nproc) # Build both tapo-agent and tapo-countdown
CARGO_TARGET_DIR=target-local cargo build --release --bin tapo-agent --bin tapo-countdown -j $(nproc)
# Copy binary to dist folder # Copy binaries to dist folder
cp "target-local/release/tapo-agent" "dist/tapo-agent-local-${HOST_TARGET}" cp "target-local/release/tapo-agent" "dist/tapo-agent-local-${HOST_TARGET}"
cp "target-local/release/tapo-countdown" "dist/tapo-countdown-local-${HOST_TARGET}"
# Get binary size # Get binary sizes
size=$(du -h "dist/tapo-agent-local-${HOST_TARGET}" | cut -f1) size_agent=$(du -h "dist/tapo-agent-local-${HOST_TARGET}" | cut -f1)
echo -e "${GREEN}dist/tapo-agent-local-${HOST_TARGET}${NC} ($size)" size_cnt=$(du -h "dist/tapo-countdown-local-${HOST_TARGET}" | cut -f1)
echo -e "${GREEN}dist/tapo-agent-local-${HOST_TARGET}${NC} ($size_agent)"
echo -e "${GREEN}dist/tapo-countdown-local-${HOST_TARGET}${NC} ($size_cnt)"
echo "" echo ""
# ============================================ # ============================================
@@ -145,14 +149,18 @@ for target in "${!TARGETS[@]}"; do
name="${TARGETS[$target]}" name="${TARGETS[$target]}"
echo -e "${GREEN}Building for $target ($name)...${NC}" echo -e "${GREEN}Building for $target ($name)...${NC}"
cross build --release --target "$target" -j $(nproc) # Build both binaries
cross build --release --target "$target" --bin tapo-agent --bin tapo-countdown -j $(nproc)
# Copy binary to dist folder with descriptive name # Copy binaries to dist folder with descriptive name
cp "target/$target/release/tapo-agent" "dist/tapo-agent-$name" cp "target/$target/release/tapo-agent" "dist/tapo-agent-$name"
cp "target/$target/release/tapo-countdown" "dist/tapo-countdown-$name"
# Get binary size # Get binary sizes
size=$(du -h "dist/tapo-agent-$name" | cut -f1) size_agent=$(du -h "dist/tapo-agent-$name" | cut -f1)
echo -e "${GREEN}dist/tapo-agent-$name${NC} ($size)" size_cnt=$(du -h "dist/tapo-countdown-$name" | cut -f1)
echo -e "${GREEN}dist/tapo-agent-$name${NC} ($size_agent)"
echo -e "${GREEN}dist/tapo-countdown-$name${NC} ($size_cnt)"
echo "" echo ""
done done
@@ -163,13 +171,13 @@ ls -lh dist/
echo "" echo ""
echo "To deploy to Raspberry Pi:" echo "To deploy to Raspberry Pi:"
echo -e " ${YELLOW}scp dist/tapo-agent-pi3_pi4_64bit pi@raspberrypi:~/tapo-agent${NC}" echo -e " ${YELLOW}scp dist/tapo-agent-pi3_pi4_64bit dist/tapo-countdown-pi3_pi4_64bit pi@raspberrypi:~/${NC}"
echo -e " ${YELLOW}ssh pi@raspberrypi 'chmod +x ~/tapo-agent && ./tapo-agent'${NC}" echo -e " ${YELLOW}ssh pi@raspberrypi 'chmod +x ~/tapo-agent-* ~/tapo-countdown-*'${NC}"
echo "" echo ""
echo -e "${BLUE}Upload to bashupload.com for web console deploy (3 days, 1 download):${NC}" echo -e "${BLUE}Upload to bashupload.com for web console deploy (3 days, 1 download):${NC}"
echo -e " ${YELLOW}curl https://bashupload.com -F=@dist/tapo-agent-pi3_pi4_64bit${NC}" echo -e " ${YELLOW}curl https://bashupload.com -F=@dist/tapo-agent-pi3_pi4_64bit${NC}"
echo -e " ${YELLOW}curl https://bashupload.com -F=@dist/tapo-agent-pi2_pi3_pi4_32bit${NC}" echo -e " ${YELLOW}curl https://bashupload.com -F=@dist/tapo-countdown-pi3_pi4_64bit${NC}"
echo "" echo ""
echo "Then on Pi, download and run:" echo "Then on Pi, download and run:"
echo -e " ${YELLOW}curl -sSL https://bashupload.com/XXXXX -o tapo-agent && chmod +x tapo-agent${NC}" echo -e " ${YELLOW}curl -sSL https://bashupload.com/XXXXX -o tapo-agent && chmod +x tapo-agent${NC}"

View 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(())
}

View File

@@ -1,6 +1,6 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use log::{error, info, warn}; use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Duration; use std::time::Duration;
use tapo::{ApiClient, DiscoveryResult}; use tapo::{ApiClient, DiscoveryResult};
@@ -91,8 +91,13 @@ struct DataMessage {
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
struct Reading { struct Reading {
device: String, device: String,
#[serde(skip)]
device_type: String,
channel: 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)] #[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 { if let Ok(info) = plug.get_device_info().await {
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
device_type: device.device_type.clone(),
channel: "state".to_string(), 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) // Time device has been ON since last state change (seconds)
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
device_type: device.device_type.clone(),
channel: "on_time".to_string(), 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) // WiFi signal level (0-3)
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
device_type: device.device_type.clone(),
channel: "signal_level".to_string(), 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) // WiFi RSSI (dBm, negative value)
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
device_type: device.device_type.clone(),
channel: "rssi".to_string(), 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 { if let Ok(energy) = plug.get_current_power().await {
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
device_type: device.device_type.clone(),
channel: "power".to_string(), 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 // Today's energy in Wh
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
device_type: device.device_type.clone(),
channel: "energy_today".to_string(), 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 // Today's runtime in minutes
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
device_type: device.device_type.clone(),
channel: "runtime_today".to_string(), 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 // This month's energy in Wh
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
device_type: device.device_type.clone(),
channel: "energy_month".to_string(), 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 // This month's runtime in minutes
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
device_type: device.device_type.clone(),
channel: "runtime_month".to_string(), channel: "runtime_month".to_string(),
value: usage.month_runtime as f64, value: Some(usage.month_runtime as f64),
data: None,
}); });
} }
// Countdown timer status // Countdown timer - return full data or null if none
if let Ok(countdown) = plug.get_countdown_rules().await { match plug.get_countdown_rules().await {
let active_countdown = countdown.rules.iter().find(|r| r.enable); Ok(countdown) => {
let active = countdown.rules.iter().find(|r| r.enable);
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
channel: "countdown_active".to_string(), device_type: device.device_type.clone(),
value: if active_countdown.is_some() { 1.0 } else { 0.0 }, channel: "countdown".to_string(),
}); value: None,
if let Some(rule) = active_countdown { data: Some(if let Some(rule) = active {
readings.push(Reading { serde_json::json!({
device: device.name.clone(), "remain": rule.remain,
channel: "countdown_remain".to_string(), "action": rule.desired_states.as_ref()
value: rule.remain as f64, .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 // Schedule rules - return full schedule list
if let Ok(schedules) = plug.get_schedule_rules().await { match plug.get_schedule_rules().await {
Ok(schedules) => {
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
channel: "schedule_count".to_string(), device_type: device.device_type.clone(),
value: schedules.rules.len() as f64, channel: "schedules".to_string(),
}); value: None,
// Count active schedules data: Some(serde_json::to_value(&schedules.rules).unwrap_or_default()),
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 {
readings.push(Reading {
device: device.name.clone(),
channel: "next_event_time".to_string(),
value: ts as f64,
}); });
} }
Err(e) => debug!("get_schedule_rules failed for {}: {}", device.name, e),
} }
} }
Err(e) => error!("Failed to connect to P110 {}: {}", 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 { if let Ok(info) = plug.get_device_info().await {
readings.push(Reading { readings.push(Reading {
device: device.name.clone(), device: device.name.clone(),
device_type: device.device_type.clone(),
channel: "state".to_string(), 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), 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() { if !all_readings.is_empty() {
info!("Collected {} readings from devices", all_readings.len()); 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 { 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 // Try to send to connection task, drop if channel full
let _ = tx.try_send(all_readings); let _ = tx.try_send(all_readings);

View File

@@ -10,14 +10,14 @@ use tokio::sync::RwLock;
use crate::error::{Error, TapoResponseError}; use crate::error::{Error, TapoResponseError};
use crate::requests::{ use crate::requests::{
ControlChildParams, DeviceRebootParams, EmptyParams, EnergyDataInterval, AddCountdownRuleParams, ControlChildParams, DeviceRebootParams, EditCountdownRuleParams,
GetChildDeviceListParams, GetEnergyDataParams, GetPowerDataParams, GetRulesParams, EmptyParams, EnergyDataInterval, GetChildDeviceListParams, GetEnergyDataParams,
LightingEffect, MultipleRequestParams, PlayAlarmParams, PowerDataInterval, TapoParams, GetPowerDataParams, GetRulesParams, LightingEffect, MultipleRequestParams, PlayAlarmParams,
TapoRequest, PowerDataInterval, TapoParams, TapoRequest,
}; };
use crate::responses::{ use crate::responses::{
ControlChildResult, CountdownRulesResult, CurrentPowerResult, DecodableResultExt, ControlChildResult, CountdownRulesResult, CurrentPowerResult, DecodableResultExt,
EnergyDataResult, EnergyDataResultRaw, EnergyUsageResult, NextEventResult, PowerDataResult, EnergyDataResult, EnergyDataResultRaw, EnergyUsageResult, PowerDataResult,
PowerDataResultRaw, ScheduleRulesResult, SupportedAlarmTypeListResult, TapoMultipleResponse, PowerDataResultRaw, ScheduleRulesResult, SupportedAlarmTypeListResult, TapoMultipleResponse,
TapoResponseExt, TapoResult, validate_response, TapoResponseExt, TapoResult, validate_response,
}; };
@@ -876,16 +876,35 @@ impl ApiClient {
.await? .await?
.ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))
} }
/// Adds or updates a countdown rule.
pub(crate) async fn add_countdown_rule(&self, delay: u64, turn_on: bool) -> Result<(), Error> {
// Check if a countdown rule already exists
let existing = self.get_countdown_rules().await.ok();
/// Gets next scheduled event. if let Some(countdown) = existing {
pub(crate) async fn get_next_event(&self) -> Result<NextEventResult, Error> { if let Some(rule) = countdown.rules.first() {
debug!("Get Next event..."); // Edit existing rule
let request = TapoRequest::GetNextEvent(TapoParams::new(EmptyParams)); debug!("Edit Countdown rule: id={}, delay={}, turn_on={}", rule.id, delay, turn_on);
let request = TapoRequest::EditCountdownRule(TapoParams::new(
EditCountdownRuleParams::new(rule.id.clone(), delay, turn_on),
));
self.get_protocol()?
.execute_request::<serde_json::Value>(request, true)
.await?;
return Ok(());
}
}
// No existing rule, add new one
debug!("Add Countdown rule: delay={}, turn_on={}", delay, turn_on);
let request = TapoRequest::AddCountdownRule(TapoParams::new(
AddCountdownRuleParams::new(delay, turn_on),
));
self.get_protocol()? self.get_protocol()?
.execute_request(request, true) .execute_request::<serde_json::Value>(request, true)
.await? .await?;
.ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) Ok(())
} }
fn get_protocol_mut(&mut self) -> Result<&mut TapoProtocol, Error> { fn get_protocol_mut(&mut self) -> Result<&mut TapoProtocol, Error> {

View File

@@ -7,7 +7,7 @@ use crate::error::Error;
use crate::requests::{EnergyDataInterval, GenericSetDeviceInfoParams, PowerDataInterval}; use crate::requests::{EnergyDataInterval, GenericSetDeviceInfoParams, PowerDataInterval};
use crate::responses::{ use crate::responses::{
CountdownRulesResult, CurrentPowerResult, DeviceInfoPlugEnergyMonitoringResult, CountdownRulesResult, CurrentPowerResult, DeviceInfoPlugEnergyMonitoringResult,
DeviceUsageEnergyMonitoringResult, EnergyDataResult, EnergyUsageResult, NextEventResult, DeviceUsageEnergyMonitoringResult, EnergyDataResult, EnergyUsageResult,
PowerDataResult, ScheduleRulesResult, PowerDataResult, ScheduleRulesResult,
}; };
@@ -98,9 +98,13 @@ impl PlugEnergyMonitoringHandler {
self.client.read().await.get_schedule_rules().await self.client.read().await.get_schedule_rules().await
} }
/// Returns *next scheduled event* as [`NextEventResult`]. /// Sets a countdown rule.
pub async fn get_next_event(&self) -> Result<NextEventResult, Error> { ///
self.client.read().await.get_next_event().await /// # Arguments
/// * `delay` - Seconds until action
/// * `turn_on` - true to turn on, false to turn off when countdown completes
pub async fn set_countdown(&self, delay: u64, turn_on: bool) -> Result<(), Error> {
self.client.read().await.add_countdown_rule(delay, turn_on).await
} }
} }

View File

@@ -5,7 +5,9 @@ use tokio::sync::{RwLock, RwLockReadGuard};
use crate::error::Error; use crate::error::Error;
use crate::requests::GenericSetDeviceInfoParams; use crate::requests::GenericSetDeviceInfoParams;
use crate::responses::{DeviceInfoPlugResult, DeviceUsageResult}; use crate::responses::{
CountdownRulesResult, DeviceInfoPlugResult, DeviceUsageResult, ScheduleRulesResult,
};
use super::{ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt}; use super::{ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt};
@@ -56,6 +58,25 @@ impl PlugHandler {
pub async fn get_device_usage(&self) -> Result<DeviceUsageResult, Error> { pub async fn get_device_usage(&self) -> Result<DeviceUsageResult, Error> {
self.client.read().await.get_device_usage().await self.client.read().await.get_device_usage().await
} }
/// Returns *countdown rules* as [`CountdownRulesResult`].
pub async fn get_countdown_rules(&self) -> Result<CountdownRulesResult, Error> {
self.client.read().await.get_countdown_rules().await
}
/// Returns *schedule rules* as [`ScheduleRulesResult`].
pub async fn get_schedule_rules(&self) -> Result<ScheduleRulesResult, Error> {
self.client.read().await.get_schedule_rules().await
}
/// Sets a countdown rule.
///
/// # Arguments
/// * `delay` - Seconds until action
/// * `turn_on` - true to turn on, false to turn off when countdown completes
pub async fn set_countdown(&self, delay: u64, turn_on: bool) -> Result<(), Error> {
self.client.read().await.add_countdown_rule(delay, turn_on).await
}
} }
#[async_trait] #[async_trait]

View File

@@ -1,5 +1,6 @@
//! Tapo request objects. //! Tapo request objects.
mod add_countdown_rule;
mod control_child; mod control_child;
mod device_reboot; mod device_reboot;
mod energy_data_interval; mod energy_data_interval;
@@ -23,6 +24,7 @@ pub use play_alarm::*;
pub use power_data_interval::*; pub use power_data_interval::*;
pub use set_device_info::*; pub use set_device_info::*;
pub(crate) use add_countdown_rule::*;
pub(crate) use control_child::*; pub(crate) use control_child::*;
pub(crate) use device_reboot::*; pub(crate) use device_reboot::*;
pub(crate) use get_child_device_list::*; pub(crate) use get_child_device_list::*;
@@ -35,3 +37,4 @@ pub(crate) use login_device::*;
pub(crate) use multiple_request::*; pub(crate) use multiple_request::*;
pub(crate) use secure_passthrough::*; pub(crate) use secure_passthrough::*;
pub(crate) use tapo_request::*; pub(crate) use tapo_request::*;

View File

@@ -0,0 +1,55 @@
//! Parameters for editing countdown rules
use serde::Serialize;
/// Parameters for editing a countdown rule
#[derive(Debug, Serialize)]
pub(crate) struct EditCountdownRuleParams {
/// Rule ID to edit
pub id: String,
/// Delay in seconds
pub delay: u64,
/// Desired states when countdown completes
pub desired_states: CountdownDesiredStates,
/// Whether to enable the rule
pub enable: bool,
}
/// Desired states for countdown
#[derive(Debug, Clone, Serialize)]
pub(crate) struct CountdownDesiredStates {
/// Whether device should be on
pub on: bool,
}
impl EditCountdownRuleParams {
pub fn new(id: String, delay: u64, turn_on: bool) -> Self {
Self {
id,
delay,
desired_states: CountdownDesiredStates { on: turn_on },
enable: true,
}
}
}
/// Parameters for adding a countdown rule
#[derive(Debug, Serialize)]
pub(crate) struct AddCountdownRuleParams {
/// Delay in seconds
pub delay: u64,
/// Desired states when countdown completes
pub desired_states: CountdownDesiredStates,
/// Whether to enable the rule
pub enable: bool,
}
impl AddCountdownRuleParams {
pub fn new(delay: u64, turn_on: bool) -> Self {
Self {
delay,
desired_states: CountdownDesiredStates { on: turn_on },
enable: true,
}
}
}

View File

@@ -7,6 +7,7 @@ pub(crate) struct GetRulesParams {
} }
impl GetRulesParams { impl GetRulesParams {
#[allow(dead_code)]
pub fn new(start_index: u32) -> Self { pub fn new(start_index: u32) -> Self {
Self { start_index } Self { start_index }
} }

View File

@@ -3,9 +3,10 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize; use serde::Serialize;
use super::{ use super::{
ControlChildParams, DeviceRebootParams, GetChildDeviceListParams, GetEnergyDataParams, AddCountdownRuleParams, ControlChildParams, DeviceRebootParams, EditCountdownRuleParams,
GetPowerDataParams, GetRulesParams, GetTriggerLogsParams, HandshakeParams, LightingEffect, GetChildDeviceListParams, GetEnergyDataParams, GetPowerDataParams, GetRulesParams,
LoginDeviceParams, MultipleRequestParams, PlayAlarmParams, SecurePassthroughParams, GetTriggerLogsParams, HandshakeParams, LightingEffect, LoginDeviceParams,
MultipleRequestParams, PlayAlarmParams, SecurePassthroughParams,
}; };
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -46,9 +47,15 @@ pub(crate) enum TapoRequest {
GetCountdownRules(TapoParams<GetRulesParams>), GetCountdownRules(TapoParams<GetRulesParams>),
#[serde(rename = "get_schedule_rules")] #[serde(rename = "get_schedule_rules")]
GetScheduleRules(TapoParams<GetRulesParams>), GetScheduleRules(TapoParams<GetRulesParams>),
#[serde(rename = "add_countdown_rule")]
AddCountdownRule(TapoParams<AddCountdownRuleParams>),
#[serde(rename = "edit_countdown_rule")]
EditCountdownRule(TapoParams<EditCountdownRuleParams>),
#[serde(rename = "get_next_event")] #[serde(rename = "get_next_event")]
#[allow(dead_code)]
GetNextEvent(TapoParams<EmptyParams>), GetNextEvent(TapoParams<EmptyParams>),
#[serde(rename = "get_antitheft_rules")] #[serde(rename = "get_antitheft_rules")]
#[allow(dead_code)]
GetAntitheftRules(TapoParams<GetRulesParams>), GetAntitheftRules(TapoParams<GetRulesParams>),
} }

View File

@@ -15,15 +15,14 @@ pub struct CountdownRule {
pub delay: u64, pub delay: u64,
/// Seconds remaining (if timer is active) /// Seconds remaining (if timer is active)
pub remain: u64, pub remain: u64,
/// Action when countdown completes: true = turn on, false = turn off /// Action when countdown completes
#[serde(rename = "desired_states")] pub desired_states: Option<DesiredState>,
pub desired_states: Option<CountdownDesiredState>,
} }
/// Desired state for countdown /// Desired state for countdown/schedule
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CountdownDesiredState { pub struct DesiredState {
/// Whether device should be on after countdown /// Whether device should be on
pub on: Option<bool>, pub on: Option<bool>,
} }
@@ -34,47 +33,39 @@ pub struct ScheduleRule {
pub id: String, pub id: String,
/// Whether the rule is enabled /// Whether the rule is enabled
pub enable: bool, pub enable: bool,
/// Weekday mask (bits 0-6 for Sun-Sat) /// Weekday mask (bits for days, 127 = all days)
#[serde(default)] #[serde(default)]
pub wday: Vec<u8>, pub week_day: u8,
/// Start minute of day (0-1439) /// Start minute of day (0-1439)
#[serde(rename = "s_min", default)] #[serde(default)]
pub start_min: u16, pub s_min: u16,
/// End minute of day (for duration schedules) /// End minute of day
#[serde(rename = "e_min", default)] #[serde(default)]
pub end_min: u16, pub e_min: u16,
/// Action: true = turn on, false = turn off /// Mode (e.g., "repeat")
#[serde(rename = "desired_states")] pub mode: Option<String>,
pub desired_states: Option<ScheduleDesiredState>, /// Day of month
} pub day: Option<u8>,
/// Month
/// Desired state for schedule pub month: Option<u8>,
#[derive(Debug, Clone, Deserialize, Serialize)] /// Year
pub struct ScheduleDesiredState { pub year: Option<u16>,
/// Whether device should be on /// Action
pub on: Option<bool>, pub desired_states: Option<DesiredState>,
}
/// Next scheduled event
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NextEventResult {
/// Schedule type
#[serde(rename = "schd_type")]
pub schedule_type: Option<String>,
/// Timestamp of next event (seconds since epoch)
pub timestamp: Option<u64>,
/// Action for the event
pub action: Option<i32>,
} }
/// Result wrapper for countdown rules /// Result wrapper for countdown rules
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct CountdownRulesResult { pub struct CountdownRulesResult {
/// Whether countdown is enabled globally
#[serde(default)]
pub enable: bool,
/// Max countdown rules
#[serde(default)]
pub countdown_rule_max_count: u32,
/// List of countdown rules /// List of countdown rules
#[serde(rename = "countdown_rules")] #[serde(rename = "rule_list", default)]
pub rules: Vec<CountdownRule>, pub rules: Vec<CountdownRule>,
/// Sum of rules (for pagination)
pub sum: Option<u32>,
} }
impl TapoResponseExt for CountdownRulesResult {} impl TapoResponseExt for CountdownRulesResult {}
@@ -82,13 +73,21 @@ impl TapoResponseExt for CountdownRulesResult {}
/// Result wrapper for schedule rules /// Result wrapper for schedule rules
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct ScheduleRulesResult { pub struct ScheduleRulesResult {
/// Whether schedule is enabled globally
#[serde(default)]
pub enable: bool,
/// Max schedule rules
#[serde(default)]
pub schedule_rule_max_count: u32,
/// List of schedule rules /// List of schedule rules
#[serde(rename = "schedule_rules")] #[serde(rename = "rule_list", default)]
pub rules: Vec<ScheduleRule>, pub rules: Vec<ScheduleRule>,
/// Sum of rules (for pagination) /// Total count (for pagination)
pub sum: Option<u32>, #[serde(default)]
pub sum: u32,
/// Start index (for pagination)
#[serde(default)]
pub start_index: u32,
} }
impl TapoResponseExt for ScheduleRulesResult {} impl TapoResponseExt for ScheduleRulesResult {}
impl TapoResponseExt for NextEventResult {}