diff --git a/agents/tapo/.gitignore b/agents/tapo/.gitignore new file mode 100644 index 0000000..bb0ba44 --- /dev/null +++ b/agents/tapo/.gitignore @@ -0,0 +1,3 @@ +target/ +target-local/ +config.toml diff --git a/agents/tapo/Cargo.toml b/agents/tapo/Cargo.toml index 27f7a79..6416d48 100644 --- a/agents/tapo/Cargo.toml +++ b/agents/tapo/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" description = "Tapo smart plug sensor data collection agent" [dependencies] -tapo = "0.8" +tapo = { path = "./tapo-fork/tapo" } tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] } futures-util = "0.3" diff --git a/agents/tapo/build-all.sh b/agents/tapo/build-all.sh index f93eb18..5e15f47 100755 --- a/agents/tapo/build-all.sh +++ b/agents/tapo/build-all.sh @@ -114,11 +114,38 @@ declare -A TARGETS=( echo -e "${BLUE}Starting builds...${NC}" echo "" +# ============================================ +# Local/Native Build +# ============================================ + +echo -e "${GREEN}Building for local/native target...${NC}" + +# Get host target +HOST_TARGET=$(rustc -vV | grep host | cut -d' ' -f2) + +# Use separate target dir for local builds to avoid GLIBC conflicts with cross builds +CARGO_TARGET_DIR=target-local cargo build --release -j $(nproc) + +# Copy binary to dist folder +cp "target-local/release/tapo-agent" "dist/tapo-agent-local-${HOST_TARGET}" + +# Get binary size +size=$(du -h "dist/tapo-agent-local-${HOST_TARGET}" | cut -f1) +echo -e " → ${GREEN}dist/tapo-agent-local-${HOST_TARGET}${NC} ($size)" +echo "" + +# ============================================ +# Cross-Compilation Builds +# ============================================ + +echo -e "${BLUE}Starting cross-compilation builds...${NC}" +echo "" + for target in "${!TARGETS[@]}"; do name="${TARGETS[$target]}" echo -e "${GREEN}Building for $target ($name)...${NC}" - cross build --release --target "$target" + cross build --release --target "$target" -j $(nproc) # Copy binary to dist folder with descriptive name cp "target/$target/release/tapo-agent" "dist/tapo-agent-$name" diff --git a/agents/tapo/src/main.rs b/agents/tapo/src/main.rs index 69448f0..93858f2 100644 --- a/agents/tapo/src/main.rs +++ b/agents/tapo/src/main.rs @@ -197,8 +197,27 @@ async fn collect_device_data(device: &DeviceConfig) -> Vec { channel: "state".to_string(), value: if info.device_on { 1.0 } else { 0.0 }, }); + // Time device has been ON since last state change (seconds) + readings.push(Reading { + device: device.name.clone(), + channel: "on_time".to_string(), + value: info.on_time as f64, + }); + // WiFi signal level (0-3) + readings.push(Reading { + device: device.name.clone(), + channel: "signal_level".to_string(), + value: info.signal_level as f64, + }); + // WiFi RSSI (dBm, negative value) + readings.push(Reading { + device: device.name.clone(), + channel: "rssi".to_string(), + value: info.rssi as f64, + }); } + // Current power in watts (API returns milliwatts) if let Ok(energy) = plug.get_current_power().await { readings.push(Reading { device: device.name.clone(), @@ -208,11 +227,74 @@ async fn collect_device_data(device: &DeviceConfig) -> Vec { } if let Ok(usage) = plug.get_energy_usage().await { + // Today's energy in Wh readings.push(Reading { device: device.name.clone(), channel: "energy_today".to_string(), value: usage.today_energy as f64, }); + // Today's runtime in minutes + readings.push(Reading { + device: device.name.clone(), + channel: "runtime_today".to_string(), + value: usage.today_runtime as f64, + }); + // This month's energy in Wh + readings.push(Reading { + device: device.name.clone(), + channel: "energy_month".to_string(), + value: usage.month_energy as f64, + }); + // This month's runtime in minutes + readings.push(Reading { + device: device.name.clone(), + channel: "runtime_month".to_string(), + value: usage.month_runtime as f64, + }); + } + + // 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 { + readings.push(Reading { + device: device.name.clone(), + channel: "countdown_remain".to_string(), + value: rule.remain as f64, + }); + } + } + + // 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 { + readings.push(Reading { + device: device.name.clone(), + channel: "next_event_time".to_string(), + value: ts as f64, + }); + } } } Err(e) => error!("Failed to connect to P110 {}: {}", device.name, e), @@ -241,6 +323,38 @@ async fn collect_device_data(device: &DeviceConfig) -> Vec { } async fn run_agent(config: Config) -> Result<(), Box> { + use tokio::sync::mpsc; + + // Channel for readings from poller to sender + let (tx, mut rx) = mpsc::channel::>(100); + + // Spawn device polling task - runs continuously regardless of connection + let poll_interval_secs = config.poll_interval_secs; + let devices = config.devices.clone(); + tokio::spawn(async move { + let mut poll_interval = interval(Duration::from_secs(poll_interval_secs)); + loop { + poll_interval.tick().await; + + let mut all_readings = Vec::new(); + for device in &devices { + let readings = collect_device_data(device).await; + all_readings.extend(readings); + } + + if !all_readings.is_empty() { + info!("Collected {} readings from devices", all_readings.len()); + // Log readings even if not connected + for reading in &all_readings { + info!(" {} {} = {}", reading.device, reading.channel, reading.value); + } + // Try to send to connection task, drop if channel full + let _ = tx.try_send(all_readings); + } + } + }); + + // Connection and sending loop let mut reconnect_delay = Duration::from_secs(1); let max_reconnect_delay = Duration::from_secs(60); @@ -283,50 +397,43 @@ async fn run_agent(config: Config) -> Result<(), Box> { continue; } - let mut poll_interval = interval(Duration::from_secs(config.poll_interval_secs)); - + // Main send loop - receive readings from channel and send to server loop { - poll_interval.tick().await; + tokio::select! { + // Receive readings from polling task + Some(readings) = rx.recv() => { + info!("Sending {} readings to server", readings.len()); + let data = DataMessage { + msg_type: "data".to_string(), + readings, + }; + let data_json = serde_json::to_string(&data)?; - 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; + 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; + // Handle incoming WebSocket messages + msg = read.next() => { + match msg { + Some(Ok(Message::Ping(data))) => { + let _ = write.send(Message::Pong(data)).await; + } + Some(Ok(Message::Close(_))) => { + info!("Server closed connection"); + break; + } + Some(Err(e)) => { + error!("WebSocket error: {}", e); + break; + } + None => { + info!("Connection closed"); + break; + } + _ => {} } - Ok(Message::Close(_)) => { - info!("Server closed connection"); - break; - } - Err(e) => { - error!("WebSocket error: {}", e); - break; - } - _ => {} } } } @@ -363,12 +470,19 @@ async fn main() -> Result<(), Box> { 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_content = match std::fs::read_to_string(config_path) { + Ok(content) => content, + Err(e) => { + eprintln!("Failed to read config file {}: {}", config_path, e); + eprintln!(); + eprintln!("Create config with device discovery:"); + eprintln!(" ./tapo-agent init --server ws://SERVER:8080 --key YOUR_KEY --email tapo@email.com --password tapopass"); + eprintln!(); + eprintln!("Or specify broadcast address:"); + eprintln!(" ./tapo-agent init --server ws://SERVER:8080 --key YOUR_KEY --email tapo@email.com --password tapopass --broadcast 192.168.0.255"); + std::process::exit(1); + } + }; let config: Config = toml::from_str(&config_content) .map_err(|e| format!("Failed to parse config: {}", e))?; diff --git a/agents/tapo/tapo-fork/.cargo/audit.toml b/agents/tapo/tapo-fork/.cargo/audit.toml new file mode 100644 index 0000000..9f1dcc2 --- /dev/null +++ b/agents/tapo/tapo-fork/.cargo/audit.toml @@ -0,0 +1,5 @@ +[advisories] +ignore = [ + # The Marvin Attack poses minimal risk to the use cases of this library + "RUSTSEC-2023-0071", +] diff --git a/agents/tapo/tapo-fork/.github/FUNDING.yml b/agents/tapo/tapo-fork/.github/FUNDING.yml new file mode 100644 index 0000000..6b837b3 --- /dev/null +++ b/agents/tapo/tapo-fork/.github/FUNDING.yml @@ -0,0 +1 @@ +github: mihai-dinculescu diff --git a/agents/tapo/tapo-fork/.github/dependabot.yml b/agents/tapo/tapo-fork/.github/dependabot.yml new file mode 100644 index 0000000..a2d15f4 --- /dev/null +++ b/agents/tapo/tapo-fork/.github/dependabot.yml @@ -0,0 +1,29 @@ +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" + - package-ecosystem: pip + directory: "/tapo-py" + schedule: + interval: "daily" + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" diff --git a/agents/tapo/tapo-fork/.github/workflows/py-ci.yml b/agents/tapo/tapo-fork/.github/workflows/py-ci.yml new file mode 100644 index 0000000..5f56311 --- /dev/null +++ b/agents/tapo/tapo-fork/.github/workflows/py-ci.yml @@ -0,0 +1,184 @@ +# This file is autogenerated by maturin v1.10.2 +# To update, run +# +# maturin generate-ci github +# +name: Python + +on: + push: + branches: + - main + tags: + - "*" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: x86 + - runner: ubuntu-22.04 + target: aarch64 + - runner: ubuntu-22.04 + target: armv7 + - runner: ubuntu-22.04 + target: s390x + - runner: ubuntu-22.04 + target: ppc64le + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path ./tapo-py/Cargo.toml + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v5 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + musllinux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: x86 + - runner: ubuntu-22.04 + target: aarch64 + - runner: ubuntu-22.04 + target: armv7 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path ./tapo-py/Cargo.toml + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v5 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: dist + + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: 3.x + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path ./tapo-py/Cargo.toml + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Upload wheels + uses: actions/upload-artifact@v5 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-15-intel + target: x86_64 + - runner: macos-latest + target: aarch64 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter --manifest-path ./tapo-py/Cargo.toml + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Upload wheels + uses: actions/upload-artifact@v5 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist --manifest-path ./tapo-py/Cargo.toml + - name: Test sdist + run: | + pip install --force-reinstall --verbose dist/*.tar.gz + python -c 'from tapo import ApiClient' + - name: Upload sdist + uses: actions/upload-artifact@v5 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} + needs: [linux, musllinux, windows, macos, sdist] + permissions: + # Use to sign the release artifacts + id-token: write + # Used to upload release artifacts + contents: write + # Used to generate artifact attestation + attestations: write + steps: + - uses: actions/download-artifact@v6 + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v3 + with: + subject-path: "wheels-*/*" + - name: Publish to PyPI + if: ${{ startsWith(github.ref, 'refs/tags/') }} + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/agents/tapo/tapo-fork/.github/workflows/rs-ci.yml b/agents/tapo/tapo-fork/.github/workflows/rs-ci.yml new file mode 100644 index 0000000..abce9ab --- /dev/null +++ b/agents/tapo/tapo-fork/.github/workflows/rs-ci.yml @@ -0,0 +1,64 @@ +name: Rust + +on: + push: + branches: + - main + tags: + - "*" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + checks: + name: Rust checks + runs-on: ubuntu-latest + steps: + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: davidB/rust-cargo-make@v1 + - uses: actions/checkout@v6 + - name: Run format + run: cargo make format + - name: Run check + run: cargo make check + - name: Run check doc + run: cargo make check-doc + - name: Run clippy + run: cargo make clippy + - name: Run test + run: cargo make test + checks_min_ver: + name: Rust checks on the minimum supported version + runs-on: ubuntu-latest + steps: + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.88.0 + - uses: davidB/rust-cargo-make@v1 + - uses: actions/checkout@v6 + - name: Run check on the minimum supported version + run: cargo +1.88.0 make check + publish: + name: Publish + runs-on: ubuntu-latest + if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} + needs: [checks, checks_min_ver] + steps: + - uses: actions/checkout@v6 + with: + ref: main + - uses: dtolnay/rust-toolchain@stable + - name: Run cargo login + run: cargo login ${CRATES_IO_TOKEN} + env: + CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} + - name: Run build + run: cargo build --package tapo --release --verbose + - name: Run cargo publish + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: cargo publish --package tapo diff --git a/agents/tapo/tapo-fork/.github/workflows/rs-security-audit.yml b/agents/tapo/tapo-fork/.github/workflows/rs-security-audit.yml new file mode 100644 index 0000000..4269e50 --- /dev/null +++ b/agents/tapo/tapo-fork/.github/workflows/rs-security-audit.yml @@ -0,0 +1,26 @@ +name: Security +on: + push: + branches: + - main + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + pull_request: + branches: + - main + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + schedule: + - cron: "0 0 * * *" +jobs: + security_audit: + name: Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: actions-rs/audit-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/agents/tapo/tapo-fork/.gitignore b/agents/tapo/tapo-fork/.gitignore new file mode 100644 index 0000000..7a0804a --- /dev/null +++ b/agents/tapo/tapo-fork/.gitignore @@ -0,0 +1,3 @@ +/target +.vscode +/.idea \ No newline at end of file diff --git a/agents/tapo/tapo-fork/CHANGELOG.md b/agents/tapo/tapo-fork/CHANGELOG.md new file mode 100644 index 0000000..d2a6949 --- /dev/null +++ b/agents/tapo/tapo-fork/CHANGELOG.md @@ -0,0 +1,800 @@ +# Change Log + +All notable changes to this project will be documented in this +file. This change log follows the conventions of +[keepachangelog.com][keepachangelog]. + +## [Rust Unreleased][Unreleased] + +## [Python Unreleased][Unreleased] + +## [Rust v0.8.8][v0.8.8] - 2025-11-23 + +### Added + +- `TapoResponseError`: added `Forbidden` variant to represent authentication failures when Third-Party Compatibility is disabled in the Tapo app. + +### Changed + +- `TapoResponseError`: renamed variant `InvalidCredentials` to `Unauthorized` and updated the variant to include `code` and `description` for improved error context. + +## [Python v0.8.8][v0.8.8] - 2025-11-23 + +### Added + +- `Tapo` Exception: added `Forbidden` variant to represent authentication failures when Third-Party Compatibility is disabled in the Tapo app. + +### Changed + +- `Tapo` Exception: renamed variant `InvalidCredentials` to `Unauthorized` and updated the variant to include `code` and `description` for improved error context. + +### Removed + +- Dropped support for Python 3.9 and 3.10 (both no longer supported upstream). The minimum required version is now Python 3.11. + +## [Rust v0.8.7][v0.8.7] - 2025-11-01 + +### Added +- `HubHandler`: added the `device_reboot` and `device_reset` methods. +- `PowerStripEnergyMonitoringHandler`: added the `device_reboot` and `device_reset` methods. +- `PowerStripHandler`: added the `device_reboot` and `device_reset` methods. +- `ColorLightHandler`: added the `device_reboot` method. +- `LightHandler`: added the `device_reboot` method. +- `PlugEnergyMonitoringHandler`: added the `device_reboot` method. +- `PlugHandler`: added the `device_reboot` method. +- `RgbicLightStripHandler`: added the `device_reboot` method. +- `RgbLightStripHandler`: added the `device_reboot` method. + +### Changed + +- `device_reset`: now requires the `DeviceManagementExt` trait to be in scope. The newly added `device_reboot` method also requires this trait. + +## [Python v0.8.7][v0.8.7] - 2025-11-01 + +### Added + +- `tapo`: added support for Python 3.14. +- `HubHandler`: added the `device_reboot` and `device_reset` methods. +- `PowerStripEnergyMonitoringHandler`: added the `device_reboot` and `device_reset` methods. +- `PowerStripHandler`: added the `device_reboot` and `device_reset` methods. +- `ColorLightHandler`: added the `device_reboot` method. +- `LightHandler`: added the `device_reboot` method. +- `PlugEnergyMonitoringHandler`: added the `device_reboot` method. +- `PlugHandler`: added the `device_reboot` method. +- `RgbicLightStripHandler`: added the `device_reboot` method. +- `RgbLightStripHandler`: added the `device_reboot` method. + +## [Rust v0.8.6][v0.8.6] - 2025-09-25 + +### Added + +- `PlugEnergyMonitoringHandler`: added `get_power_data` method to retrieve historical power data (every 5 minutes & hourly) for energy-monitoring plugs (P110, P110M, P115). The `PowerDataInterval` enum allows specifying the desired interval. +- `PowerStripPlugEnergyMonitoringHandler`: added the following energy monitoring methods: `get_current_power`, `get_device_usage`, `get_energy_usage`, `get_energy_data`, `get_power_data`. + +### Changed + +- `PlugEnergyMonitoringHandler`: `EnergyDataResult` has been redesigned to provide better ergonomics by attaching a start date time to each interval entry. + +### Removed + +- `EnergyUsageResult`: the `current_power` field has been removed from the struct because not all energy-monitoring plugs provide this data. Instead, use the `get_current_power` method to retrieve the current power. + +## [Python v0.8.6][v0.8.6] - 2025-09-25 + +### Added + +- `PlugEnergyMonitoringHandler`: added `get_power_data` method to retrieve historical power data (every 5 minutes & hourly) for energy-monitoring plugs (P110, P110M, P115). The `PowerDataInterval` enum allows specifying the desired interval. +- `PowerStripPlugEnergyMonitoringHandler`: added the following energy monitoring methods: `get_current_power`, `get_device_usage`, `get_energy_usage`, `get_energy_data`, `get_power_data`. + +### Changed + +- `PlugEnergyMonitoringHandler`: `EnergyDataResult` has been redesigned to provide better ergonomics by attaching a start date time to each interval entry. + +### Removed + +- `EnergyUsageResult`: the `current_power` field has been removed from the class because not all energy-monitoring plugs provide this data. Instead, use the `get_current_power` method to retrieve the current power. + +## [Rust v0.8.5][v0.8.5] - 2025-09-18 + +### Added + +- `ApiClient`: added `discover_devices` method to discover all Tapo devices on the local network. This works even with dynamic or unknown IPs, but is slower since it scans the entire network and waits for device responses. +- `PowerStripPlugHandler`: added support for P306 power strips. +- Added `PowerStripEnergyMonitoringHandler`, `PowerStripPlugEnergyMonitoringHandler`, and `PowerStripPlugEnergyMonitoringResult` to support energy-monitoring power strips (P304M, P316M). Non-monitoring models (P300, P306) will continue using the pre-existing `PowerStripPlugHandler`. +- `PowerStripPlugResult`: added `default_states` field. + +### Changed + +- `DeviceInfoPowerStripResult`: changed `time_diff` from `Option` to `i64`. + +### Fixed + +- `PowerStripPlugResult`: removed `charging_status`, `overcurrent_status`, and `power_protection_status` (not returned by P300/P306). + +## [Python v0.8.5][v0.8.5] - 2025-09-18 + +### Added + +- `ApiClient`: added `discover_devices` method to discover all Tapo devices on the local network. This works even with dynamic or unknown IPs, but is slower since it scans the entire network and waits for device responses. +- `PowerStripPlugHandler`: added support for P306 power strips. +- Added `PowerStripEnergyMonitoringHandler`, `PowerStripPlugEnergyMonitoringHandler`, and `PowerStripPlugEnergyMonitoringResult` to support energy-monitoring power strips (P304M, P316M). Non-monitoring models (P300, P306) will continue using the pre-existing `PowerStripPlugHandler`. +- `PowerStripPlugResult`: added `default_states` field. + +### Changed + +- `DeviceInfoPowerStripResult`: changed `time_diff` from `Optional[int]` to `int`. + +### Fixed + +- `PowerStripPlugResult`: removed `charging_status`, `overcurrent_status`, and `power_protection_status` (not returned by P300/P306). + +## [Rust v0.8.4][v0.8.4] - 2025-08-11 + +### Added + +- The `default_states` property has been added to the `DeviceInfoPlugResult` struct to provide a more comprehensive overview of the plug's state. + +### Fixed + +- The `default_states` property value of the `DeviceInfoPlugEnergyMonitoringResult` struct has been changed from a struct to an enum to better reflect the actual response from the device. + +## [Python v0.8.4][v0.8.4] - 2025-08-11 + +### Added + +- The `default_states` property has been added to the `DeviceInfoPlugResult` class to provide a more comprehensive overview of the plug's state. + +### Fixed + +- The `default_states` property value of the `DeviceInfoPlugEnergyMonitoringResult` class has been changed from a class to an enum to better reflect the actual response from the device. + +## [Rust v0.8.3][v0.8.3] - 2025-07-25 + +### Added + +- Added support for the P316M power strips. +- The `charging_status`, `overcurrent_status`, and `power_protection_status` fields have been added to `PowerStripPlugResult`. + +### Changed + +- Enhanced the `InvalidCredentials` error with clearer guidance on common causes and how to address them. +- The `overheat_status` field in `PowerStripPlugResult` is now optional to support devices that omit this field. + +### Removed + +- Removed `nickname` from `DeviceInfoPowerStripResult` because it is not present in the response. + +## [Python v0.8.3][v0.8.3] - 2025-07-25 + +### Added + +- Added support for the P316M power strips. +- The `charging_status`, `overcurrent_status`, and `power_protection_status` fields have been added to `PowerStripPlugResult`. + +### Changed + +- Enhanced the `InvalidCredentials` error with clearer guidance on common causes and how to address them. +- The `overheat_status` field in `PowerStripPlugResult` is now optional to support devices that omit this field. + +### Removed + +- Removed `nickname` from `DeviceInfoPowerStripResult` because it is not present in the response. + +## [Rust v0.8.2][v0.8.2] - 2025-05-19 + +### Added + +- The `charging_status` field has been added to `DeviceInfoPlugEnergyMonitoringResult`. + +### Changed + +- The `overheat_status` field in `DeviceInfoPlugEnergyMonitoringResult` is now optional to support devices that omit this field after the latest firmware update (1.3.4 Build 250403 Rel.150504). + +## [Python v0.8.2][v0.8.2] - 2025-05-19 + +### Added + +- The `charging_status` field has been added to `DeviceInfoPlugEnergyMonitoringResult`. + +### Changed + +- The `overheat_status` field in `DeviceInfoPlugEnergyMonitoringResult` is now optional to support devices that omit this field after the latest firmware update (1.3.4 Build 250403 Rel.150504). + +## [Rust v0.8.1][v0.8.1] - 2025-02-10 + +### Added + +- Added functionality for controlling the alarm on the H100 hub via the `play_alarm` and `stop_alarm` methods in the `H100Handler`. Additionally, `get_supported_ringtone_list` is available to retrieve the list of supported ringtones for debugging purposes. (thanks to @kay) +- Added the ability to retrieve the color configuration (`hue`, `saturation`, `color_temperature`) for the `Color` enum values through the `get_color_config` method. (thanks to @WhySoBad) + +### Changed + +- The internal implementation of `H100Handler`'s `get_child_device_list` has been updated to fetch all pages, not just the first one. +- `H100Handler`'s `get_child_device_list_json` now includes a `start_index` parameter to fetch child devices starting from a specific index. + +### Fixed + +- Resolved an issue that caused the passthrough protocol test to incorrectly indicate support when it was not actually supported. (thanks to @WhySoBad) + +## [Python v0.8.1][v0.8.1] - 2025-02-10 + +### Added + +- Added functionality for controlling the alarm on the H100 hub via the `play_alarm` and `stop_alarm` methods in the `H100Handler`. Additionally, `get_supported_ringtone_list` is available to retrieve the list of supported ringtones for debugging purposes. +- Added the ability to retrieve the color configuration (`hue`, `saturation`, `color_temperature`) for the `Color` enum values through the `get_color_config` method. (thanks to @WhySoBad) + +### Changed + +- The internal implementation of `H100Handler`'s `get_child_device_list` has been updated to fetch all pages, not just the first one. +- `H100Handler`'s `get_child_device_list_json` now includes a `start_index` parameter to fetch child devices starting from a specific index. + +### Fixed + +- Resolved an issue that caused the passthrough protocol test to incorrectly indicate support when it was not actually supported. (thanks to @WhySoBad) + +## [Rust v0.8.0][v0.8.0] - 2024-12-07 + +This marks the first unified release of the Rust and Python libraries. Moving forward, both libraries will be released simultaneously and will share the same version number. + +### Added + +- Added an example for the L900 light strips. + +### Changed + +- `LightingEffect`'s `fadeoff` field has been renamed to `fade_off`, and its `with_fadeoff` method has been renamed to `with_fade_off`. +- `LightingEffect`'s `new_with_random_id` function has been removed. The `new` function now creates a `LightingEffect` instance with a random ID by default. + +## [Python v0.8.0][v0.8.0] - 2024-12-07 + +### Added + +- Added support for the L900 light strips. +- Added support for the L920 and L930 light strips. +- Added support for Python 3.13. + +## [Python v0.7.0][py-v0.7.0] - 2024-11-07 + +### Added + +- Added support for the KE100 thermostatic radiator valve (TRV). + +## [Rust v0.7.17][v0.7.17] - 2024-10-23 + +### Added + +- Added support for the P304 power strip. + +### Changed + +- The `openssl` dependency has been replaced with native Rust alternatives to expand cross-compilation options, such as for Android, and to decrease build times (thanks to @rbock44). +- `PlugPowerStripHandler` has been renamed to `PowerStripPlugHandler` to be consistent with the rest of the library. +- `PlugPowerStripResult` has been renamed to `PowerStripPlugResult` to be consistent with the rest of the library. +- The `UsageByPeriodResult` fields `today`, `past7`, and `past30` have been updated to `Option` to handle cases where the API returns negative values, which will be represented as `None`. + +### Fixed + +- Updated all comments referencing Watts to confirm the correct units are specified. + +## [Python v0.6.0][py-v0.6.0] - 2024-10-23 + +### Added + +- Added support for the P300 and P304 power strips. +- Python logs can now capture entries from the underlying Rust library. + +### Changed + +- The `openssl` dependency has been replaced with native Rust alternatives to expand cross-compilation options, such as for Android, and to decrease build times (thanks to @rbock44). +- The `UsageByPeriodResult` fields `today`, `past7`, and `past30` have been updated to `Optional[int]` to handle cases where the API returns negative values, which will be represented as `null`. + +### Fixed + +- Updated all comments referencing Watts to confirm the correct units are specified. + +## [Rust v0.7.16][v0.7.16] - 2024-09-27 + +### Added + +- Added support for the L535 light bulbs. + +### Fixed + +- Fixed an issue that prevented the color from being set properly for the L535 light bulbs. + +## [Python v0.5.1][py-v0.5.1] - 2024-09-27 + +### Added + +- Added support for the L535 light bulbs. + +### Fixed + +- Fixed an issue that prevented the color from being set properly for the L535 light bulbs. + +## [Rust v0.7.15][v0.7.15] - 2024-09-18 + +### Added + +- The `LowBattery` variant has been added to the `S200BLog` enum. + +### Changed + +- The `t310` and `t315` methods of `HubHandler` can now create `T31XHandler` handlers for either of the two device types. +- The child device handlers for the H100 hub and the P300 power strip have been redesigned to eliminate the use of lifetimes, to facilitate FFI integrations. +- The comments of `start_timestamp` and `end_timestamp` fields in `EnergyDataResult` have been updated to better describe their purpose. +- `S200BRotationParams`'s field `degrees` has been renamed to `rotation_degrees`. + +### Fixed + +- Fixed an issue with the `Color` presets that triggered a validation error when attempting to set the `color` to `DarkRed`. + +### Removed + +- The deprecated `past24h`, `past7d`, `past30d` and `past1y` fields have been removed from `EnergyUsageResult`. This data is now available exclusively through `get_energy_data`'s `EnergyDataResult` response. + +## [Python v0.5.0][py-v0.5.0] - 2024-09-18 + +### Added + +- Added full support for the S200B switches through the `S200BHandler` handler. +- Added full support for the T100 sensors through the `T100Handler` handler. +- Added full support for the T110 sensors through the `T110Handler` handler. +- Added full support for the T300 sensors through the `T300Handler` handler. +- Added full support for the T310 and T315 sensors through the `T31XHandler` handler. + +### Changed + +- The comments of `start_timestamp` and `end_timestamp` fields in `EnergyDataResult` have been updated to better describe their purpose. + +### Fixed + +- Fixed an issue with the `Color` presets that triggered a validation error when attempting to set the `color` to `DarkRed`. + +## [Rust v0.7.14][v0.7.14] - 2024-08-31 + +### Changed + +- `DeviceInfoPlugEnergyMonitoringResult` has been added to support the P110 and P115 devices, which have different responses compared to the P100 and P105 devices. + +### Fixed + +- `DeviceInfoPlugResult` has been updated to correctly support the P100 and P105 devices. + +## [Python v0.4.0][py-v0.4.0] - 2024-08-31 + +### Changed + +- `DeviceInfoPlugEnergyMonitoringResult` has been added to support the P110 and P115 devices, which have different responses compared to the P100 and P105 devices. + +### Fixed + +- Resolved an issue that led to unrecoverable process hangs when a device request timed out. +- The concurrency of device handlers has been significantly enhanced by replacing all `Mutex` instances with `RwLock`. +- `DeviceInfoPlugResult` has been updated to correctly support the P100 and P105 devices. + +## [Rust v0.7.13][v0.7.13] - 2024-08-26 + +### Changed + +- To align with the latest API updates, the `overheated` field for plugs has been replaced by three enums: `overcurrent_status`, `overheat_status`, and `power_protection_status` (thanks to @padenot). + +## [Python v0.3.2][py-v0.3.2] - 2024-08-26 + +### Changed + +- To align with the latest API updates, the `overheated` field for plugs has been replaced by three enums: `overcurrent_status`, `overheat_status`, and `power_protection_status`. + +## [Rust v0.7.12][v0.7.12] - 2024-06-27 + +### Changed + +- H100's create child device handler methods now take a `HubDevice` `enum` instead of a `String` and are now `async` to allow for more flexibility. This enables the caller to find child devices by either device ID or nickname. +- `PlugIdentifier` has been renamed to `Plug`. +- `Plug::ByDeviceId` now verifies that the provided device ID is found and returns an `Error::DeviceNotFound` error when it's not. +- `HubDevice` variants now take a `String` instead of a `&str` to allow for more flexibility. +- `Plug` variants now take a `String` instead of a `&str` to allow for more flexibility. + +### Fixed + +- `ColorLightSetDeviceInfoParams` `hue` field validation has been changed from `between 1 and 360` to `between 0 and 360` to match the device's expected range. +- Fixed an issue where the `EnergyDataResult`'s `start_timestamp` and `end_timestamp` did not correctly adjust for timezone offsets. +- The `chrono` dependency has been updated to `0.4.34` to fix the minimum version requirement. + +### Removed + +- The `overheated` property has been removed from `DeviceInfoGenericResult` because it's not present in the response of all devices. + +## [Python v0.3.1][py-v0.3.1] - 2024-06-27 + +### Fixed + +- `ColorLightSetDeviceInfoParams` `hue` field validation has been changed from `between 1 and 360` to `between 0 and 360` to match the device's expected range. +- Fixed an issue where the `EnergyDataResult`'s `start_timestamp` and `end_timestamp` did not correctly adjust for timezone offsets. +- All handlers are now correctly exported and can be imported from the `tapo` module. + +### Removed + +- The `overheated` property has been removed from `DeviceInfoGenericResult` because it's not present in the response of all devices. + +## [Rust v0.7.11][v0.7.11] - 2024-05-04 + +### Added + +- Added support for the P300 power strip (thanks to @Michal-Szczepaniak). +- `RgbLightStripHandler` and `DeviceInfoRgbLightStripResult` have been added to support the L900 devices separately from the L530 and L630 devices. + +### Changed + +- `ChildDeviceResult` has been renamed to `ChildDeviceHubResult` to facilitate adding support for other devices with children. +- `ColorLightStripHandler` has been renamed to `RgbicLightStripHandler` to better reflect its purpose. +- `DeviceInfoColorLightStripResult` has been renamed to `DeviceInfoRgbicLightStripResult` to better reflect its purpose. + +## [Python v0.3.0][py-v0.3.0] - 2024-05-04 + +### Added + +- Added partial support for the H100 hub and its child devices. Currently, only the `get_device_info` function is supported for the child devices through the hub's `get_child_device_list` method. + +### Changed + +- A large number of types have been reorganized to me more in line with the Rust library. This includes moving many of them under the `requests` and `responses` sub modules. + +### Removed + +- `l900` has been removed from the `ApiClient` until proper support is added. + +## [Rust v0.7.10][v0.7.10] - 2024-04-05 + +### Changed + +- The implementation of `ApiClient::new` has been improved to allow for the return of `ApiClient` instead of `Result`. +- The default timeout for all requests has been reduced to 30 seconds from 300 seconds. +- `ApiClient::with_timeout` has been added to allow for the setting of a custom timeout for all requests (thanks to @skoky). + +## [Python v0.2.1][py-v0.2.1] - 2024-04-05 + +### Changed + +- The default timeout for all requests has been reduced to 30 seconds from 300 seconds. +- The `timeout_s` optional parameter has been added to the `ApiClient` constructor to allow for the setting of a custom timeout for all requests (thanks to @skoky). + +## [Rust v0.7.9][v0.7.9] - 2024-01-27 + +### Changed + +- The `send()` method of the `.set()` API now takes a reference to the device handler in order to allow for better ergonomics. + +### Fixed + +- The device info response for the L510, L520, and L610 devices has been fixed. + +## [Python v0.2.0][py-v0.2.0] - 2024-01-27 + +### Added + +- Added support for the L530, L630, and L900 color light bulbs. + +### Fixed + +- Fixed a misconfiguration that was preventing the sdist package from working properly. +- The device info response for the L510, L520, and L610 devices has been fixed. + +## [Rust v0.7.8][v0.7.8] - 2024-01-22 + +### Added + +- Added the `device_reset` method to all plugs and lights. + +### Fixed + +- The device info response for the L510, L520, and L610 devices has been fixed to have the `re_power_type` field as optional. + +## [Python v0.1.5][py-v0.1.5] - 2024-01-22 + +### Added + +- Added the `device_reset` method to all plugs and lights. + +### Fixed + +- The device info response for the L510, L520, and L610 devices has been fixed to have the `re_power_type` field as optional. + +## [Rust v0.7.7][v0.7.7] - 2024-01-13 + +### Changed + +- The `anyhow::anyhow!("Local hash does not match server hash")` error has been replaced with the more specific `tapo::TapoResponseError::InvalidCredentials` error. + +### Fixed + +- The `default_states` field that's part of the device info response has been changed for the L510, L520, and L610 devices to match the actual response from the device. +- A handful of edge cases around the Klap Protocol that were causing panics have been fixed to return `tapo::TapoResponseError::SessionTimeout` or `tapo::TapoResponseError::InvalidResponse` errors instead. + +## [Python v0.1.4][py-v0.1.4] - 2024-01-13 + +### Changed + +- The "Local hash does not match server hash" error has been replaced with the more specific `tapo::TapoResponseError::InvalidCredentials` error. + +### Fixed + +- The `default_states` field that's part of the device info response has been changed for the L510, L520, and L610 devices to match the actual response from the device. +- A handful of edge cases around the Klap Protocol that were causing panics have been fixed to return `tapo::TapoResponseError::SessionTimeout` or `tapo::TapoResponseError::InvalidResponse` errors instead. + +## [Rust v0.7.6][v0.7.6] - 2023-11-25 + +### Added + +- Added support for the KE100 thermostatic radiator valve (TRV) devices (thanks to @pwoerndle). + +### Fixed + +- Fixed an issue that was preventing the `nickname` field from being decoded in the `get_device_info` results of child devices of the H100 hub. + +## [Rust v0.7.5][v0.7.5] - 2023-11-05 + +### Added + +- Added support for the T300 water sensor. +- Added a dedicated handler for the L520 devices. + +## [Python v0.1.3][py-v0.1.3] - 2023-11-04 + +### Added + +- Added support for the L510, L520 and L610 light bulbs. + +### Changed + +- The minimum required version of Python has been changed to 3.8, up from 3.7. + +### Fixed + +- Fixed an issue that was preventing `get_device_info_json` from working on the plug devices. + +## [Python v0.1.2][py-v0.1.2] - 2023-10-19 + +### Added + +- Added support for generic devices. +- Added `get_device_info_json` to all currently supported devices. + +## [Python v0.1.1][py-v0.1.1] - 2023-10-01 + +This is the first version of the Python wrapper library. +It supports the plug devices P100, P105, P110 and P115. + +## [v0.7.4] - 2023-09-15 + +### Fixed + +- Fixed the minimum version of the chrono dependency by setting it to 0.4.25. + +### Changes + +- `DeviceUsageResult` has been split into `DeviceUsageResult` and `DeviceUsageEnergyMonitoringResult`. The former is now returned for the P100 and P105 devices while the latter is returned for all the other devices that support energy monitoring. +- `EnergyMonitoringPlugHandler` has been renamed to `PlugEnergyMonitoringHandler`. +- All `___DeviceInfoResult` structs have been renamed to `DeviceInfo___Result`. +- All `___DefaultState` structs have been renamed to `Default___State`. + +### Removed + +- `get_device_usage` has been removed from the `GenericDeviceHandler` because it is not supported by all devices. + +## [v0.7.3] - 2023-09-14 + +### Added + +- Added support for the newly introduced KLAP protocol, which is required to interact with the latest firmware version of multiple devices. + +### Changed + +- All uses of `time` have been replaced with `chrono`: + - `EnergyDataInterval`'s `time::OffsetDateTime` and `time::Date` fields have been replaced with `chrono::NaiveDate`. + - `EnergyUsageResult::local_time` field is now `chrono::NaiveDateTime` instead of `time::OffsetDateTime`. + - `EnergyDataResult::local_time` field is now `chrono::NaiveDateTime` instead of `time::OffsetDateTime`. + - `TemperatureHumidityRecords`'s and `TemperatureHumidityRecord` `datetime` fields are now `chrono::DateTime` instead of `time::OffsetDateTime`. +- `EnergyDataInterval::Hourly::start_datetime` and `EnergyDataInterval::Hourly::end_datetime` have been renamed to `start_date` and `end_date` because the time component is not required. +- The `login` function on all handlers has been renamed to `refresh_session` to better reflect its purpose and it now takes and returns a `&mut self` instead of `self`. +- `L510DeviceInfoResult` has been renamed to `LightDeviceInfoResult` to better reflect its purpose when used for L510 and L610 devices. +- `L530DeviceInfoResult` has been renamed to `ColorLightDeviceInfoResult` to better reflect its purpose when used for L530, L630 and L900 devices. +- `L930DeviceInfoResult` has been renamed to `ColorLightStripDeviceInfoResult` to better reflect its purpose when used for L920 and L930 devices. +- The `default_states` field of `LightDeviceInfoResult`, `ColorLightDeviceInfoResult`, `ColorLightStripDeviceInfoResult` and `PlugDeviceInfoResult` is now a struct instead of an enum. + +## [v0.7.2] - 2023-08-21 + +### Added + +- Added `get_current_power` to the `P110` and `P115` plugs. (thanks to @Michal-Szczepaniak) + +## [v0.7.1] - 2023-05-30 + +### Added + +- Added `get_temperature_humidity_records` to the `T310` and `T315` sensors. + +### Changed + +- The creation of device handlers has been simplified. + +```rust +// old +let device = ApiClient::new(ip_address, tapo_username, tapo_password)? + .l530() + .login() + .await?; + +// new +let device = ApiClient::new(tapo_username, tapo_password)? + .l530(ip_address) + .await?; +``` + +- The creation of child device handlers has been reworked so that they can be created without requiring a call to `get_child_device_list` when the child Device ID is known. +- `ApiClient` now implements `Clone` to allow for a cheaper duplication of the client. + +### Removed + +- The `L510` and `L610` devices no longer expose the `set()` API because changing multiple properties simultaneously does not make sense for these devices. + +## [v0.7.0] - 2023-05-26 + +### Added + +- Added initial support for the H100 device, the S200B switch and the T100, T110, T310, T315 sensors. The child devices currently support `get_device_info` and `get_trigger_logs`. +- All responses now derive `serde::Serialize` to allow for more straightforward consumer serialisation. (thanks to @ClementNerma) +- `ApiClient` has been marked as both `Send` and `Sync` to allow for sharing between threads. (thanks to @ClementNerma) + +### Changed + +- `GenericDeviceInfoResult`'s `device_on` property has been made optional to accommodate devices that do not provide this field. + +## [v0.6.0] - 2023-05-08 + +### Added + +- Added support for the L920 and L930 light strips. The highlight is the `tapo::ColorLightStripHandler::set_lighting_effect` function, which supports all the effects that the Tapo app contains alongside user-defined effects. +- Added support for the L900 light strips. +- Each supported device now has it's own handler creator. + +### Changed + +- `set_*` functions like `tapo::requests::ColorLightSetDeviceInfoParams::set_brightness` now return `Self` instead of `Result` to allow for better ergonomics. The validations will now run when `tapo::requests::ColorLightSetDeviceInfoParams::send` is called. +- `tapo::requests::L510SetDeviceInfoParams` has been renamed to `tapo::requests::LightSetDeviceInfoParams` to better reflect its purpose when used for L510, L610, and L900 devices. +- `tapo::requests::L530SetDeviceInfoParams` has been renamed to `tapo::requests::ColorLightSetDeviceInfoParams` to better reflect its purpose when used for L530, L630, L920 and L930 devices. +- `tapo::P100Handler` has been renamed to `tapo::PlugHandler`. +- `tapo::P110Handler` has been renamed to `tapo::EnergyMonitoringPlugHandler`. +- `tapo::L510Handler` has been renamed to `tapo::LightHandler`. +- `tapo::L530Handler` has been renamed to `tapo::ColorLightHandler`. +- `tapo::L930Handler` has been renamed to `tapo::ColorLightStripHandler`. + +## [v0.5.0] - 2023-04-16 + +### Changed + +- The creation of an API Client for a specific device is now done through handler methods on the `ApiClient` struct. This allows for a more ergonomic API. (thanks to [Octocrab](https://github.com/XAMPPRocky/octocrab) for inspirations) + +```rust +// old +let device = ApiClient::::new(ip_address, tapo_username, tapo_password, true).await?; + +// new +let device = ApiClient::new(ip_address, tapo_username, tapo_password)? + .l530() + .login() + .await?; +``` + +- `ApiClient::new` parameters are now `impl Into` instead of `String` to allow for more flexibility. +- Error handling has been reworked. All functions that could error now return a `Result<..., tapo::Error>`. + +## [v0.4.0] - 2023-02-25 + +### Added + +- `get_energy_data` is now available for the *P110* devices. (thanks to @kuhschnappel) + +### Changed + +- `EnergyUsageResult`'s `past24h`, `past7d`, `past30d` and `past1y` fields are now deprecated. `get_energy_data` should be used instead. (thanks to @felixhauptmann) + +## [v0.3.1] - 2023-02-19 + +### Added + +- `examples/tapo_generic_device_toggle.rs` demonstrates how `device_info` can be used to assess the current status of a generic device and toggle it. + +### Changed + +- `on_time` is now optional for the `L510` and `L530` devices because the v2 hardware no longer returns it. + +## [v0.3.0] - 2022-11-20 + +### Added + +- The `set` API allows multiple properties to be set in a single request for the _L510_ and _L530_ devices. + +### Changed + +- `tapo::Color` has been moved to `tapo::requests::Color`. +- `GenericDeviceInfoResult::on_time` has been changed from `u64` to `Option` because some devices (like *L930*) do not provide this field. +- All response structs have been moved under `tapo::responses`. +- The docs have been improved. + +## [v0.2.1] - 2022-08-07 + +### Changed + +- `latitude` and `longitude` in `GenericDeviceInfoResult`, `L510DeviceInfoResult`, `L530DeviceInfoResult` and `PlugDeviceInfoResult` are now signed integers to accommodate for incoming responses with negative numbers. (thanks to @JPablomr) + +## [v0.2.0] - 2022-06-13 + +### Added + +- Generic Device example. + +### Changed + +- `get_device_usage` has been moved to the base implementation so that all devices have access to it. +- `Color` now implements `serde::Serialize` and `serde::Deserialize`. + +### Removed + +- `TapoDeviceExt` is no longer has `Default` and `serde::Serialize` as supersets. + +## [v0.1.0] - 2022-06-07 + +### Initial Release of Tapo + +[Unreleased]: https://github.com/mihai-dinculescu/tapo +[v0.8.7]: https://github.com/mihai-dinculescu/tapo/tree/v0.8.7 +[v0.8.6]: https://github.com/mihai-dinculescu/tapo/tree/v0.8.6 +[v0.8.5]: https://github.com/mihai-dinculescu/tapo/tree/v0.8.5 +[v0.8.4]: https://github.com/mihai-dinculescu/tapo/tree/v0.8.4 +[v0.8.3]: https://github.com/mihai-dinculescu/tapo/tree/v0.8.3 +[v0.8.2]: https://github.com/mihai-dinculescu/tapo/tree/v0.8.2 +[v0.8.1]: https://github.com/mihai-dinculescu/tapo/tree/v0.8.1 +[v0.8.0]: https://github.com/mihai-dinculescu/tapo/tree/v0.8.0 +[py-v0.7.0]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.7.0 +[v0.7.17]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.17 +[py-v0.6.0]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.6.0 +[v0.7.16]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.16 +[py-v0.5.1]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.5.1 +[v0.7.15]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.15 +[py-v0.5.0]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.5.0 +[v0.7.14]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.14 +[py-v0.4.0]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.4.0 +[v0.7.13]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.13 +[py-v0.3.2]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.3.2 +[v0.7.12]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.12 +[py-v0.3.1]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.3.1 +[v0.7.11]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.11 +[py-v0.3.0]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.3.0 +[v0.7.10]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.10 +[py-v0.2.1]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.2.1 +[v0.7.9]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.9 +[py-v0.2.0]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.2.0 +[v0.7.8]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.8 +[py-v0.1.5]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.1.5 +[v0.7.7]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.7 +[py-v0.1.4]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.1.4 +[v0.7.6]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.6 +[v0.7.5]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.5 +[py-v0.1.3]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.1.3 +[py-v0.1.2]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.1.2 +[py-v0.1.1]: https://github.com/mihai-dinculescu/tapo/tree/py-v0.1.1 +[v0.7.4]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.4 +[v0.7.3]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.3 +[v0.7.2]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.2 +[v0.7.1]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.1 +[v0.7.0]: https://github.com/mihai-dinculescu/tapo/tree/v0.7.0 +[v0.6.0]: https://github.com/mihai-dinculescu/tapo/tree/v0.6.0 +[v0.5.0]: https://github.com/mihai-dinculescu/tapo/tree/v0.5.0 +[v0.4.0]: https://github.com/mihai-dinculescu/tapo/tree/v0.4.0 +[v0.3.1]: https://github.com/mihai-dinculescu/tapo/tree/v0.3.1 +[v0.3.0]: https://github.com/mihai-dinculescu/tapo/tree/v0.3.0 +[v0.2.1]: https://github.com/mihai-dinculescu/tapo/tree/v0.2.1 +[v0.2.0]: https://github.com/mihai-dinculescu/tapo/tree/v0.2.0 +[v0.1.0]: https://github.com/mihai-dinculescu/tapo/tree/v0.1.0 +[keepachangelog]: https://keepachangelog.com diff --git a/agents/tapo/tapo-fork/CONTRIBUTING.md b/agents/tapo/tapo-fork/CONTRIBUTING.md new file mode 100644 index 0000000..c362872 --- /dev/null +++ b/agents/tapo/tapo-fork/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +Contributions are welcome and encouraged! See [/issues][issues] for ideas, or suggest your own! +If you're thinking to create a PR with large feature/change, please first discuss it in an issue. + +[issues]: https://github.com/mihai-dinculescu/tapo/issues + +## Releasing new versions + +- Update version in `tapo/Cargo.toml` +- Update version in `tapo-py/pyproject.toml` (two places) +- Update CHANGELOG.md +- Commit +- Add tag + + ```bash + git tag -a vX.X.X -m "vX.X.X" + ``` + +- Push + + ```bash + git push --follow-tags + ``` + +- Create the [release][release]. + +[releases]: https://github.com/mihai-dinculescu/tapo/releases diff --git a/agents/tapo/tapo-fork/Cargo.toml b/agents/tapo/tapo-fork/Cargo.toml new file mode 100644 index 0000000..e512c23 --- /dev/null +++ b/agents/tapo/tapo-fork/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +resolver = "3" + +members = ["tapo", "tapo-py"] + +[workspace.dependencies] +anyhow = "1.0" +chrono = { version = "0.4.34", default-features = false } +log = "0.4" +pyo3 = { version = "0.27" } +serde = { version = "1.0" } +serde_json = { version = "1.0" } +tokio = { version = "1.48", default-features = false } diff --git a/agents/tapo/tapo-fork/LICENSE b/agents/tapo/tapo-fork/LICENSE new file mode 100644 index 0000000..5161992 --- /dev/null +++ b/agents/tapo/tapo-fork/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2025 Mihai Dinculescu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/agents/tapo/tapo-fork/Makefile.toml b/agents/tapo/tapo-fork/Makefile.toml new file mode 100644 index 0000000..8598c95 --- /dev/null +++ b/agents/tapo/tapo-fork/Makefile.toml @@ -0,0 +1,29 @@ +[env] +CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true + +[config] +skip_core_tasks = true + +[tasks.format] +command = "cargo" +args = ["fmt", "--verbose", "--", "--check"] + +[tasks.check] +command = "cargo" +args = ["check", "--verbose"] + +[tasks.check-doc] +env = { "RUSTDOCFLAGS" = "-D warnings" } +command = "cargo" +args = ["doc", "--no-deps"] + +[tasks.clippy] +command = "cargo" +args = ["clippy", "--all-targets", "--all-features", "--verbose", "--", "-D", "warnings"] + +[tasks.test] +command = "cargo" +args = ["test", "--verbose"] + +[tasks.ci-flow] +dependencies = ["format", "check", "check-doc", "clippy", "test"] diff --git a/agents/tapo/tapo-fork/README.md b/agents/tapo/tapo-fork/README.md new file mode 100644 index 0000000..0a8275d --- /dev/null +++ b/agents/tapo/tapo-fork/README.md @@ -0,0 +1,111 @@ +# Tapo + + +[![License][license_badge]][license] +[![Crates][crates_badge]][crates] +[![Documentation][crates_documentation_badge]][crates_documentation] +[![Crates.io][crates_downloads_badge]][crates] +[![PyPI][pypi_badge]][pypi] +[![Python][pypi_versions_badge]][pypi] +[![PyPI][pypi_downloads_badge]][pypi]\ +Unofficial Tapo API Client. Works with TP-Link Tapo smart devices. Tested with light bulbs (L510, L520, L530, L535, L610, L630), light strips (L900, L920, L930), plugs (P100, P105, P110, P110M, P115), power strips (P300, P304M, P306, P316M), hubs (H100), switches (S200B) and sensors (KE100, T100, T110, T300, T310, T315). + +[license_badge]: https://img.shields.io/crates/l/tapo.svg +[license]: https://github.com/mihai-dinculescu/tapo/blob/main/LICENSE +[crates_badge]: https://img.shields.io/crates/v/tapo.svg?logo=rust&color=F75101 +[crates]: https://crates.io/crates/tapo +[crates_documentation_badge]: https://img.shields.io/docsrs/tapo.svg?logo=rust&color=F75101 +[crates_documentation]: https://docs.rs/tapo +[crates_downloads_badge]: https://img.shields.io/crates/d/tapo?logo=rust&label=downloads&color=F75101 + +[pypi_badge]: https://img.shields.io/pypi/v/tapo.svg?logo=pypi&color=00ADD4 +[pypi]: https://pypi.org/project/tapo +[pypi_versions_badge]: https://img.shields.io/pypi/pyversions/tapo.svg?logo=python&color=00ADD4 +[pypi_downloads_badge]: https://img.shields.io/pypi/dm/tapo?logo=python&color=00ADD4 + +## Supported Devices + +See [/SUPPORTED_DEVICES.md][supported_devices] for the supported devices and feature matrix. + +## Rust + +### Usage + +> Cargo.toml +```toml +[dependencies] +tapo = "0.8" +``` + +> main.rs +```rust +let device = ApiClient::new("", "tapo-password") + .p110("") + .await?; + +device.on().await?; +``` + +### Examples + +```bash +export TAPO_USERNAME= +export TAPO_PASSWORD= +export IP_ADDRESS= + +cargo run --example tapo_l530 +``` + +See all examples in [/tapo/examples][examples]. + +### Wrapper REST API +[tapo-rest][tapo_rest] is a REST wrapper of this library that can be deployed as a service or serve as an advanced example. + +## Python + +### Usage + +```bash +pip install tapo +``` + +```python +client = ApiClient("", "tapo-password") +device = await client.p110("") + +await device.on() +``` + +### Examples + +```bash +cd tapo-py +poetry install # On the initial run +poetry shell +maturin develop # On the initial run and whenever the Rust code is modified + +export TAPO_USERNAME= +export TAPO_PASSWORD= +export IP_ADDRESS= +``` + +```bash +python examples/tapo_p110.py +``` + +See all examples in [/tapo-py/examples][examples-py]. + +## Contributing + +Contributions are welcome and encouraged! See [/CONTRIBUTING.md][contributing]. + +## Credits + +Inspired by [petretiandrea/plugp100][inspired_by]. + +[supported_devices]: https://github.com/mihai-dinculescu/tapo/blob/main/SUPPORTED_DEVICES.md +[examples]: https://github.com/mihai-dinculescu/tapo/tree/main/tapo/examples +[examples-py]: https://github.com/mihai-dinculescu/tapo/tree/main/tapo-py/examples +[tapo_rest]: https://github.com/ClementNerma/tapo-rest +[contributing]: https://github.com/mihai-dinculescu/tapo/blob/main/CONTRIBUTING.md +[inspired_by]: https://github.com/petretiandrea/plugp100 diff --git a/agents/tapo/tapo-fork/SUPPORTED_DEVICES.md b/agents/tapo/tapo-fork/SUPPORTED_DEVICES.md new file mode 100644 index 0000000..27bf109 --- /dev/null +++ b/agents/tapo/tapo-fork/SUPPORTED_DEVICES.md @@ -0,0 +1,74 @@ + +# Supported devices + +✓ - Rust only\ +✅ - Rust and Python + +| Feature


| GenericDevice


| L510
L520
L610
| L530
L535
L630
| L900


| L920
L930

| P100
P105

| P110
P110M
P115
| P300
P306

| P304M
P316M

| H100


| +| ------------------------------------ | :--------------------------- | :-------------------------- | :-------------------------- | :------------------ | :---------------------- | :---------------------- | :--------------------------- | :---------------------- | :------------------------ | :------------------ | +| device_reboot | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| device_reset | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| get_child_device_component_list_json | | | | | | | | ✅ | ✅ | ✅ | +| get_child_device_list | | | | | | | | ✅ | ✅ | ✅ | +| get_child_device_list_json | | | | | | | | ✅ | ✅ | ✅ | +| get_current_power | | | | | | | ✅ | | | | +| get_device_info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| get_device_info_json | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| get_device_usage | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | +| get_energy_data | | | | | | | ✅ | | | | +| get_energy_usage | | | | | | | ✅ | | | | +| get_power_data | | | | | | | ✅ | | | | +| get_supported_ringtone_list | | | | | | | | | | ✅ | +| off | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | +| on | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | +| play_alarm | | | | | | | | | | ✅ | +| refresh_session | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| set_brightness | | ✅ | ✅ | ✅ | ✅ | | | | | | +| set_color | | | ✅ | ✅ | ✅ | | | | | | +| set_color_temperature | | | ✅ | ✅ | ✅ | | | | | | +| set_hue_saturation | | | ✅ | ✅ | ✅ | | | | | | +| set_lighting_effect | | | | | ✅ | | | | | | +| set() API \* | | | ✅ | ✅ | ✅ | | | | | | +| stop_alarm | | | | | | | | | | ✅ | + + +\* The `set()` API allows multiple properties to be set in a single request. + +## Hub (H100) Child Devices + +✓ - Rust only\ +✅ - Rust and Python + +| Feature

| KE100

| S200B

| T100

| T110

| T300

| T310
T315 | +| -------------------------------- | :-------------- | :-------------- | :------------- | :------------- | :------------- | :------------ | +| get_device_info \* | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| get_device_info_json | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| get_temperature_humidity_records | | | | | | ✅ | +| get_trigger_logs | | ✅ | ✅ | ✅ | ✅ | | +| set_child_protection | ✅ | | | | | | +| set_frost_protection | ✅ | | | | | | +| set_max_control_temperature | ✅ | | | | | | +| set_min_control_temperature | ✅ | | | | | | +| set_target_temperature | ✅ | | | | | | +| set_temperature_offset | ✅ | | | | | | + +\* Obtained by calling `get_child_device_list` on the hub device or `get_device_info` on a child device handler. + +## Power Strips Child Devices + +✓ - Rust only\ +✅ - Rust and Python + +| Feature

| P300
P306
| P304M
P316M
| +| -------------------- | :----------------- | :------------------- | +| get_current_power | | ✅ | +| get_device_info \* | ✅ | ✅ | +| get_device_info_json | ✅ | ✅ | +| get_device_usage | | ✅ | +| get_energy_data | | ✅ | +| get_energy_usage | | ✅ | +| get_power_data | | ✅ | +| off | ✅ | ✅ | +| on | ✅ | ✅ | + +\* Obtained by calling `get_child_device_list` on the hub device or `get_device_info` on a child device handler. diff --git a/agents/tapo/tapo-fork/rustfmt.toml b/agents/tapo/tapo-fork/rustfmt.toml new file mode 100644 index 0000000..5265deb --- /dev/null +++ b/agents/tapo/tapo-fork/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 100 +style_edition = "2024" diff --git a/agents/tapo/tapo-fork/taplo.toml b/agents/tapo/tapo-fork/taplo.toml new file mode 100644 index 0000000..ccfe63c --- /dev/null +++ b/agents/tapo/tapo-fork/taplo.toml @@ -0,0 +1,2 @@ +[formatting] +column_width = 100 diff --git a/agents/tapo/tapo-fork/tapo-py/.gitignore b/agents/tapo/tapo-fork/tapo-py/.gitignore new file mode 100644 index 0000000..d05629f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +.pytest_cache +**/*.so +**/*.pyd diff --git a/agents/tapo/tapo-fork/tapo-py/CHANGELOG.md b/agents/tapo/tapo-fork/tapo-py/CHANGELOG.md new file mode 120000 index 0000000..04c99a5 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/CHANGELOG.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/agents/tapo/tapo-fork/tapo-py/Cargo.toml b/agents/tapo/tapo-fork/tapo-py/Cargo.toml new file mode 100644 index 0000000..1c6c01d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "tapo-py" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +publish = false + +[lib] +name = "tapo" +crate-type = ["cdylib"] +doc = false + +[features] +default = [] + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true } +log = { workspace = true } +pyo3 = { workspace = true, features = [ + "chrono", + "experimental-async", + "extension-module", + "py-clone", +] } +pyo3-async-runtimes = { version = "0.27", features = ["attributes", "tokio-runtime"] } +pyo3-log = { version = "0.13" } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "sync"] } + +tapo = { path = "../tapo", features = ["python"] } diff --git a/agents/tapo/tapo-fork/tapo-py/LICENSE b/agents/tapo/tapo-fork/tapo-py/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/agents/tapo/tapo-fork/tapo-py/README.md b/agents/tapo/tapo-fork/tapo-py/README.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_discover_devices.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_discover_devices.py new file mode 100644 index 0000000..1f6ae1c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_discover_devices.py @@ -0,0 +1,70 @@ +"""Discover devices on the local network Example""" + +import asyncio +import os + +from tapo import ApiClient, DiscoveryResult + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + target = os.getenv("TARGET", "192.168.1.255") + timeout_s = int(os.getenv("TIMEOUT", 10)) + + print(f"Discovering Tapo devices on target: {target} for {timeout_s} seconds...") + + api_client = ApiClient(tapo_username, tapo_password) + discovery = await api_client.discover_devices(target, timeout_s) + + async for discovery_result in discovery: + try: + device = discovery_result.get() + + match device: + case DiscoveryResult.GenericDevice(device_info, _handler): + print( + f"Found Unsupported Device '{device_info.nickname}' of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + case DiscoveryResult.Light(device_info, _handler): + print( + f"Found '{device_info.nickname}' of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + case DiscoveryResult.ColorLight(device_info, _handler): + print( + f"Found '{device_info.nickname}' of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + case DiscoveryResult.RgbLightStrip(device_info, _handler): + print( + f"Found '{device_info.nickname}' of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + case DiscoveryResult.RgbicLightStrip(device_info, _handler): + print( + f"Found '{device_info.nickname}' of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + case DiscoveryResult.Plug(device_info, _handler): + print( + f"Found '{device_info.nickname}' of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + case DiscoveryResult.PlugEnergyMonitoring(device_info, _handler): + print( + f"Found '{device_info.nickname}' of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + case DiscoveryResult.PowerStrip(device_info, _handler): + print( + f"Found Power Strip of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + case DiscoveryResult.PowerStripEnergyMonitoring(device_info, _handler): + print( + f"Found Power Strip with Energy Monitoring of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + case DiscoveryResult.Hub(device_info, _handler): + print( + f"Found '{device_info.nickname}' of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + except Exception as e: + print(f"Error discovering device: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_generic_device.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_generic_device.py new file mode 100644 index 0000000..a0effd2 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_generic_device.py @@ -0,0 +1,31 @@ +"""Generic Device Example""" + +import asyncio +import os + +from tapo import ApiClient + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + device = await client.generic_device(ip_address) + + print("Turning device on...") + await device.on() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Turning device off...") + await device.off() + + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_generic_device_toggle.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_generic_device_toggle.py new file mode 100644 index 0000000..b126d8c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_generic_device_toggle.py @@ -0,0 +1,30 @@ +"""Toggle Generic Device Example""" + +import asyncio +import os + +from tapo import ApiClient + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + device = await client.generic_device(ip_address) + + device_info = await device.get_device_info() + + if device_info.device_on == True: + print("Device is on. Turning it off...") + await device.off() + elif device_info.device_on == False: + print("Device is off. Turning it on...") + await device.on() + else: + print("This device does not support on/off functionality.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_h100.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_h100.py new file mode 100644 index 0000000..96d8260 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_h100.py @@ -0,0 +1,120 @@ +"""H100 Example""" + +import asyncio +import os + +from tapo import ApiClient +from tapo.requests import AlarmRingtone, AlarmVolume, AlarmDuration +from tapo.responses import KE100Result, S200BResult, T100Result, T110Result, T300Result, T31XResult + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + hub = await client.h100(ip_address) + + device_info = await hub.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + child_device_list = await hub.get_child_device_list() + + for child in child_device_list: + if child is None: + print("Found unsupported device.") + elif isinstance(child, KE100Result): + print( + "Found KE100 child device with nickname: {}, id: {}, current temperature: {:.2f} {} and target temperature: {:.2f} {}.".format( + child.nickname, + child.device_id, + child.current_temperature, + child.temperature_unit, + child.target_temperature, + child.temperature_unit, + ) + ) + elif isinstance(child, S200BResult): + s200b = await hub.s200b(device_id=child.device_id) + trigger_logs = await s200b.get_trigger_logs(5, 0) + + print( + "Found S200B child device with nickname: {}, id: {}, last 5 trigger logs: {}.".format( + child.nickname, + child.device_id, + [log.to_dict() for log in trigger_logs.logs], + ) + ) + elif isinstance(child, T100Result): + t100 = await hub.t100(device_id=child.device_id) + trigger_logs = await t100.get_trigger_logs(5, 0) + + print( + "Found T100 child device with nickname: {}, id: {}, detected: {}, last 5 trigger logs: {}.".format( + child.nickname, + child.device_id, + child.detected, + [log.to_dict() for log in trigger_logs.logs], + ) + ) + elif isinstance(child, T110Result): + t110 = await hub.t110(device_id=child.device_id) + trigger_logs = await t110.get_trigger_logs(5, 0) + + print( + "Found T110 child device with nickname: {}, id: {}, open: {}, last 5 trigger logs: {}.".format( + child.nickname, + child.device_id, + child.open, + [log.to_dict() for log in trigger_logs.logs], + ) + ) + elif isinstance(child, T300Result): + t300 = await hub.t300(device_id=child.device_id) + trigger_logs = await t300.get_trigger_logs(5, 0) + + print( + "Found T300 child device with nickname: {}, id: {}, in_alarm: {}, water_leak_status: {}, last 5 trigger logs: {}.".format( + child.nickname, + child.device_id, + child.in_alarm, + child.water_leak_status, + [log.to_dict() for log in trigger_logs.logs], + ) + ) + elif isinstance(child, T31XResult): + t31x = await hub.t315(device_id=child.device_id) + temperature_humidity_records = await t31x.get_temperature_humidity_records() + + print( + "Found T31X child device with nickname: {}, id: {}, temperature: {:.2f} {}, humidity: {}%, earliest temperature and humidity record available: {}.".format( + child.nickname, + child.device_id, + child.current_temperature, + child.temperature_unit, + child.current_humidity, + ( + temperature_humidity_records.records[0].to_dict() + if temperature_humidity_records.records + else None + ), + ) + ) + + print(f"Triggering the alarm ringtone 'Alarm 1' at a 'Low' volume for '3 Seconds'...") + await hub.play_alarm(AlarmRingtone.Alarm1, AlarmVolume.Low, AlarmDuration.Seconds, seconds=3) + + device_info = await hub.get_device_info() + print(f"Is device ringing?: {device_info.in_alarm}") + + print("Stopping the alarm after 1 Second...") + await asyncio.sleep(1) + await hub.stop_alarm() + + device_info = await hub.get_device_info() + print(f"Is device ringing?: {device_info.in_alarm}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_ke100.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_ke100.py new file mode 100644 index 0000000..9859c1d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_ke100.py @@ -0,0 +1,40 @@ +"""KE100 TRV Example""" + +import asyncio +import os + +from tapo import ApiClient +from tapo.requests import TemperatureUnitKE100 + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + # Name of the KE100 device. + # Can be obtained from the Tapo App or by executing `get_child_device_component_list()` on the hub device. + device_name = os.getenv("DEVICE_NAME") + target_temperature = int(os.getenv("TARGET_TEMPERATURE")) + + client = ApiClient(tapo_username, tapo_password) + hub = await client.h100(ip_address) + + # Get a handler for the child device + device = await hub.ke100(nickname=device_name) + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + # Set target temperature. + # KE100 currently only supports Celsius as temperature unit. + print(f"Setting target temperature to {target_temperature} degrees Celsius...") + await device.set_target_temperature(target_temperature, TemperatureUnitKE100.Celsius) + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_l510.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_l510.py new file mode 100644 index 0000000..f80cf39 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_l510.py @@ -0,0 +1,40 @@ +"""L510, L520 and L610 Example""" + +import asyncio +import os + +from tapo import ApiClient + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + device = await client.l510(ip_address) + + print("Turning device on...") + await device.on() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting the brightness to 30%...") + await device.set_brightness(30) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Turning device off...") + await device.off() + + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + device_usage = await device.get_device_usage() + print(f"Device usage: {device_usage.to_dict()}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_l530.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_l530.py new file mode 100644 index 0000000..e95a2c2 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_l530.py @@ -0,0 +1,62 @@ +"""L530, L535 and L630 Example""" + +import asyncio +import os + +from tapo import ApiClient +from tapo.requests import Color + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + device = await client.l530(ip_address) + + print("Turning device on...") + await device.on() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting the brightness to 30%...") + await device.set_brightness(30) + + print("Setting the color to `Chocolate`...") + await device.set_color(Color.Chocolate) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting the color to `Deep Sky Blue` using the `hue` and `saturation`...") + await device.set_hue_saturation(195, 100) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting the color to `Incandescent` using the `color temperature`...") + await device.set_color_temperature(2700) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Using the `set` API to set multiple properties in a single request...") + await device.set().brightness(50).color(Color.HotPink).send(device) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Turning device off...") + await device.off() + + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + device_usage = await device.get_device_usage() + print(f"Device usage: {device_usage.to_dict()}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_l900.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_l900.py new file mode 100644 index 0000000..9d36835 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_l900.py @@ -0,0 +1,62 @@ +"""L900 Example""" + +import asyncio +import os + +from tapo import ApiClient +from tapo.requests import Color + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + device = await client.l900(ip_address) + + print("Turning device on...") + await device.on() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting the brightness to 30%...") + await device.set_brightness(30) + + print("Setting the color to `Chocolate`...") + await device.set_color(Color.Chocolate) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting the color to `Deep Sky Blue` using the `hue` and `saturation`...") + await device.set_hue_saturation(195, 100) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting the color to `Incandescent` using the `color temperature`...") + await device.set_color_temperature(2700) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Using the `set` API to set multiple properties in a single request...") + await device.set().brightness(50).color(Color.HotPink).send(device) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Turning device off...") + await device.off() + + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + device_usage = await device.get_device_usage() + print(f"Device usage: {device_usage.to_dict()}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_l930.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_l930.py new file mode 100644 index 0000000..a753a79 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_l930.py @@ -0,0 +1,103 @@ +"""L920 and L930 Example""" + +import asyncio +import os + +from tapo import ApiClient +from tapo.requests import Color, LightingEffect, LightingEffectPreset, LightingEffectType + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + device = await client.l930(ip_address) + + print("Turning device on...") + await device.on() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting the brightness to 30%...") + await device.set_brightness(30) + + print("Setting the color to `Chocolate`...") + await device.set_color(Color.Chocolate) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting the color to `Deep Sky Blue` using the `hue` and `saturation`...") + await device.set_hue_saturation(195, 100) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting the color to `Incandescent` using the `color temperature`...") + await device.set_color_temperature(2700) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Using the `set` API to set multiple properties in a single request...") + await device.set().brightness(50).color(Color.HotPink).send(device) + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Setting a preset Lighting effect...") + await device.set_lighting_effect(LightingEffectPreset.BubblingCauldron) + + print("Waiting 10 seconds...") + await asyncio.sleep(10) + + print("Setting a custom static Lighting effect...") + custom_effect = ( + LightingEffect( + "My Custom Static Effect", LightingEffectType.Static, True, True, 100, [(359, 85, 100)] + ) + .with_expansion_strategy(1) + .with_segments([0, 1, 2]) + .with_sequence([(359, 85, 100), (0, 0, 100), (236, 72, 100)]) + ) + await device.set_lighting_effect(custom_effect) + + print("Waiting 10 seconds...") + await asyncio.sleep(10) + + print("Setting a custom sequence Lighting effect...") + custom_effect = ( + LightingEffect( + "My Custom Sequence Effect", + LightingEffectType.Sequence, + True, + True, + 100, + [(359, 85, 100)], + ) + .with_expansion_strategy(1) + .with_segments([0, 1, 2]) + .with_sequence([(359, 85, 100), (0, 0, 100), (236, 72, 100)]) + .with_direction(1) + .with_duration(50) + ) + await device.set_lighting_effect(custom_effect) + + print("Waiting 10 seconds...") + await asyncio.sleep(10) + + print("Turning device off...") + await device.off() + + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + device_usage = await device.get_device_usage() + print(f"Device usage: {device_usage.to_dict()}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_p100.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_p100.py new file mode 100644 index 0000000..7435645 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_p100.py @@ -0,0 +1,34 @@ +"""P100 and P105 Example""" + +import asyncio +import os + +from tapo import ApiClient + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + device = await client.p100(ip_address) + + print("Turning device on...") + await device.on() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Turning device off...") + await device.off() + + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + device_usage = await device.get_device_usage() + print(f"Device usage: {device_usage.to_dict()}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_p110.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_p110.py new file mode 100644 index 0000000..080dafd --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_p110.py @@ -0,0 +1,118 @@ +"""P110, P110M and P115 Example""" + +import asyncio +import os +from datetime import datetime, timedelta, timezone + +from tapo import ApiClient +from tapo.requests import EnergyDataInterval, PowerDataInterval + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + device = await client.p110(ip_address) + + print("Turning device on...") + await device.on() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Turning device off...") + await device.off() + + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + current_power = await device.get_current_power() + print(f"Current power: {current_power.to_dict()}") + + device_usage = await device.get_device_usage() + print(f"Device usage: {device_usage.to_dict()}") + + energy_usage = await device.get_energy_usage() + print(f"Energy usage: {energy_usage.to_dict()}") + + today = datetime.now(timezone.utc) + + # Energy data - Hourly interval + # `start_date` and `end_date` are an inclusive interval that must not be greater than 8 days. + energy_data_hourly = await device.get_energy_data(EnergyDataInterval.Hourly, today) + print( + "Energy data (hourly): " + f"Start date time '{energy_data_hourly.start_date_time}', " + f"Entries {len(energy_data_hourly.entries)}, " + f"First entry: {energy_data_hourly.entries[0].to_dict() if energy_data_hourly.entries else None}" + ) + + # Energy data - Daily interval + # `start_date` must be the first day of a quarter. + energy_data_daily = await device.get_energy_data( + EnergyDataInterval.Daily, + datetime(today.year, get_quarter_start_month(today), 1), + ) + print( + "Energy data (daily): " + f"Start date time '{energy_data_daily.start_date_time}', " + f"Entries {len(energy_data_daily.entries)}, " + f"First entry: {energy_data_daily.entries[0].to_dict() if energy_data_daily.entries else None}" + ) + + # Energy data - Monthly interval + # `start_date` must be the first day of a year. + energy_data_monthly = await device.get_energy_data( + EnergyDataInterval.Monthly, + datetime(today.year, 1, 1), + ) + print( + "Energy data (monthly): " + f"Start date time '{energy_data_monthly.start_date_time}', " + f"Entries {len(energy_data_monthly.entries)}, " + f"First entry: {energy_data_monthly.entries[0].to_dict() if energy_data_monthly.entries else None}" + ) + + # Power data - Every 5 minutes interval + # `start_date_time` and `end_date_time` describe an exclusive interval. + # If the result would yield more than 144 entries (i.e. 12 hours), + # the `end_date_time` will be adjusted to an earlier date and time. + power_data_every_5_minutes = await device.get_power_data( + PowerDataInterval.Every5Minutes, + today - timedelta(hours=12), + today, + ) + print( + "Power data (every 5 minutes): " + f"Start date time '{power_data_every_5_minutes.start_date_time}', " + f"End date time '{power_data_every_5_minutes.end_date_time}', " + f"Entries {len(power_data_every_5_minutes.entries)}, " + f"First entry: {power_data_every_5_minutes.entries[0].to_dict() if power_data_every_5_minutes.entries else None}" + ) + + # Power data - Hourly interval + # `start_date_time` and `end_date_time` describe an exclusive interval. + # If the result would yield more than 144 entries (i.e. 6 days), + # the `end_date_time` will be adjusted to an earlier date and time. + power_data_hourly = await device.get_power_data( + PowerDataInterval.Hourly, + today - timedelta(days=3), + today, + ) + print( + "Power data (hourly): " + f"Start date time '{power_data_hourly.start_date_time}', " + f"End date time '{power_data_hourly.end_date_time}', " + f"Entries {len(power_data_hourly.entries)}, " + f"First entry: {power_data_hourly.entries[0].to_dict() if power_data_hourly.entries else None}" + ) + + +def get_quarter_start_month(today: datetime) -> int: + return ((today.month - 1) // 3) * 3 + 1 + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_p300.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_p300.py new file mode 100644 index 0000000..a73afdb --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_p300.py @@ -0,0 +1,45 @@ +"""P300 and P306 Example""" + +import asyncio +import os + +from tapo import ApiClient + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + power_strip = await client.p300(ip_address) + + device_info = await power_strip.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + print("Getting child devices...") + child_device_list = await power_strip.get_child_device_list() + print(f"Found {len(child_device_list)} plugs") + + for index, child in enumerate(child_device_list): + print(f"=== ({index + 1}) {child.nickname} ===") + print(f"Device ID: {child.device_id}") + print(f"State: {child.device_on}") + + plug = await power_strip.plug(device_id=child.device_id) + + print("Turning device on...") + await plug.on() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Turning device off...") + await plug.off() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/examples/tapo_p304.py b/agents/tapo/tapo-fork/tapo-py/examples/tapo_p304.py new file mode 100644 index 0000000..f02c474 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/examples/tapo_p304.py @@ -0,0 +1,132 @@ +"""P304M and P316M Example""" + +import asyncio +from datetime import datetime, timedelta, timezone +import os + +from tapo import ApiClient +from tapo.requests import EnergyDataInterval, PowerDataInterval + + +async def main(): + tapo_username = os.getenv("TAPO_USERNAME") + tapo_password = os.getenv("TAPO_PASSWORD") + ip_address = os.getenv("IP_ADDRESS") + + client = ApiClient(tapo_username, tapo_password) + power_strip = await client.p304(ip_address) + + device_info = await power_strip.get_device_info() + print(f"Device info: {device_info.to_dict()}") + + print("Getting child devices...") + child_device_list = await power_strip.get_child_device_list() + print(f"Found {len(child_device_list)} plugs") + + for index, child in enumerate(child_device_list): + print(f"=== ({index + 1}) {child.nickname} ===") + print(f"Device ID: {child.device_id}") + print(f"State: {child.device_on}") + + plug = await power_strip.plug(device_id=child.device_id) + + print("Turning device on...") + await plug.on() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + print("Turning device off...") + await plug.off() + + print("Waiting 2 seconds...") + await asyncio.sleep(2) + + current_power = await plug.get_current_power() + print(f"Current power: {current_power.to_dict()}") + + device_usage = await plug.get_device_usage() + print(f"Device usage: {device_usage.to_dict()}") + + energy_usage = await plug.get_energy_usage() + print(f"Energy usage: {energy_usage.to_dict()}") + + today = datetime.now(timezone.utc) + + # Energy data - Hourly interval + # `start_date` and `end_date` are an inclusive interval that must not be greater than 8 days. + energy_data_hourly = await plug.get_energy_data(EnergyDataInterval.Hourly, today) + print( + "Energy data (hourly): " + f"Start date time '{energy_data_hourly.start_date_time}', " + f"Entries {len(energy_data_hourly.entries)}, " + f"First entry: {energy_data_hourly.entries[0].to_dict() if energy_data_hourly.entries else None}" + ) + + # Energy data - Daily interval + # `start_date` must be the first day of a quarter. + energy_data_daily = await plug.get_energy_data( + EnergyDataInterval.Daily, + datetime(today.year, get_quarter_start_month(today), 1), + ) + print( + "Energy data (daily): " + f"Start date time '{energy_data_daily.start_date_time}', " + f"Entries {len(energy_data_daily.entries)}, " + f"First entry: {energy_data_daily.entries[0].to_dict() if energy_data_daily.entries else None}" + ) + + # Energy data - Monthly interval + # `start_date` must be the first day of a year. + energy_data_monthly = await plug.get_energy_data( + EnergyDataInterval.Monthly, + datetime(today.year, 1, 1), + ) + print( + "Energy data (monthly): " + f"Start date time '{energy_data_monthly.start_date_time}', " + f"Entries {len(energy_data_monthly.entries)}, " + f"First entry: {energy_data_monthly.entries[0].to_dict() if energy_data_monthly.entries else None}" + ) + + # Power data - Every 5 minutes interval + # `start_date_time` and `end_date_time` describe an exclusive interval. + # If the result would yield more than 144 entries (i.e. 12 hours), + # the `end_date_time` will be adjusted to an earlier date and time. + power_data_every_5_minutes = await plug.get_power_data( + PowerDataInterval.Every5Minutes, + today - timedelta(hours=12), + today, + ) + print( + "Power data (every 5 minutes): " + f"Start date time '{power_data_every_5_minutes.start_date_time}', " + f"End date time '{power_data_every_5_minutes.end_date_time}', " + f"Entries {len(power_data_every_5_minutes.entries)}, " + f"First entry: {power_data_every_5_minutes.entries[0].to_dict() if power_data_every_5_minutes.entries else None}" + ) + + # Power data - Hourly interval + # `start_date_time` and `end_date_time` describe an exclusive interval. + # If the result would yield more than 144 entries (i.e. 6 days), + # the `end_date_time` will be adjusted to an earlier date and time. + power_data_hourly = await plug.get_power_data( + PowerDataInterval.Hourly, + today - timedelta(days=3), + today, + ) + print( + "Power data (hourly): " + f"Start date time '{power_data_hourly.start_date_time}', " + f"End date time '{power_data_hourly.end_date_time}', " + f"Entries {len(power_data_hourly.entries)}, " + f"First entry: {power_data_hourly.entries[0].to_dict() if power_data_hourly.entries else None}" + ) + + +def get_quarter_start_month(today: datetime) -> int: + return ((today.month - 1) // 3) * 3 + 1 + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/tapo/tapo-fork/tapo-py/poetry.lock b/agents/tapo/tapo-fork/tapo-py/poetry.lock new file mode 100644 index 0000000..029eea1 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/poetry.lock @@ -0,0 +1,180 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "black" +version = "25.11.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"}, + {file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"}, + {file = "black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37"}, + {file = "black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03"}, + {file = "black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a"}, + {file = "black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170"}, + {file = "black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc"}, + {file = "black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e"}, + {file = "black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac"}, + {file = "black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96"}, + {file = "black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd"}, + {file = "black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409"}, + {file = "black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b"}, + {file = "black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd"}, + {file = "black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993"}, + {file = "black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c"}, + {file = "black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170"}, + {file = "black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545"}, + {file = "black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda"}, + {file = "black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664"}, + {file = "black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06"}, + {file = "black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2"}, + {file = "black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc"}, + {file = "black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc"}, + {file = "black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b"}, + {file = "black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +pytokens = ">=0.3.0" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.3.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "maturin" +version = "1.10.2" +description = "Build and publish crates with pyo3, cffi and uniffi bindings as well as rust binaries as python packages" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "maturin-1.10.2-py3-none-linux_armv6l.whl", hash = "sha256:11c73815f21a755d2129c410e6cb19dbfacbc0155bfc46c706b69930c2eb794b"}, + {file = "maturin-1.10.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7fbd997c5347649ee7987bd05a92bd5b8b07efa4ac3f8bcbf6196e07eb573d89"}, + {file = "maturin-1.10.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3ce9b2ad4fb9c341f450a6d32dc3edb409a2d582a81bc46ba55f6e3b6196b22"}, + {file = "maturin-1.10.2-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:f0d1b7b5f73c8d30a7e71cd2a2189a7f0126a3a3cd8b3d6843e7e1d4db50f759"}, + {file = "maturin-1.10.2-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:efcd496a3202ffe0d0489df1f83d08b91399782fb2dd545d5a1e7bf6fd81af39"}, + {file = "maturin-1.10.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a41ec70d99e27c05377be90f8e3c3def2a7bae4d0d9d5ea874aaf2d1da625d5c"}, + {file = "maturin-1.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:07a82864352feeaf2167247c8206937ef6c6ae9533025d416b7004ade0ea601d"}, + {file = "maturin-1.10.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:04df81ee295dcda37828bd025a4ac688ea856e3946e4cb300a8f44a448de0069"}, + {file = "maturin-1.10.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96e1d391e4c1fa87edf2a37e4d53d5f2e5f39dd880b9d8306ac9f8eb212d23f8"}, + {file = "maturin-1.10.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a217aa7c42aa332fb8e8377eb07314e1f02cf0fe036f614aca4575121952addd"}, + {file = "maturin-1.10.2-py3-none-win32.whl", hash = "sha256:da031771d9fb6ddb1d373638ec2556feee29e4507365cd5749a2d354bcadd818"}, + {file = "maturin-1.10.2-py3-none-win_amd64.whl", hash = "sha256:da777766fd584440dc9fecd30059a94f85e4983f58b09e438ae38ee4b494024c"}, + {file = "maturin-1.10.2-py3-none-win_arm64.whl", hash = "sha256:a4c29a770ea2c76082e0afc6d4efd8ee94405588bfae00d10828f72e206c739b"}, + {file = "maturin-1.10.2.tar.gz", hash = "sha256:259292563da89850bf8f7d37aa4ddba22905214c1e180b1c8f55505dfd8c0e81"}, +] + +[package.extras] +patchelf = ["patchelf"] +zig = ["ziglang (>=0.10.0,<0.13.0)"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, + {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, +] + +[package.extras] +docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] +type = ["mypy (>=1.18.2)"] + +[[package]] +name = "pytokens" +version = "0.3.0" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"}, + {file = "pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.11" +content-hash = "fdca2529430378024bd7592d1cc9a036dce731d069014afa9422296fe281758a" diff --git a/agents/tapo/tapo-fork/tapo-py/py.typed b/agents/tapo/tapo-fork/tapo-py/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/agents/tapo/tapo-fork/tapo-py/pyproject.toml b/agents/tapo/tapo-fork/tapo-py/pyproject.toml new file mode 100644 index 0000000..51056e8 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/pyproject.toml @@ -0,0 +1,55 @@ +[tool.poetry] +name = "tapo" +version = "0.8.8" +description = "Unofficial Tapo API Client. Works with TP-Link Tapo smart devices. Tested with light bulbs (L510, L520, L530, L535, L610, L630), light strips (L900, L920, L930), plugs (P100, P105, P110, P110M, P115), power strips (P300, P304M, P306, P316M), hubs (H100), switches (S200B) and sensors (KE100, T100, T110, T300, T310, T315)." +authors = ["Mihai Dinculescu "] + +[project] +name = "tapo" +version = "0.8.8" +description = "Unofficial Tapo API Client. Works with TP-Link Tapo smart devices. Tested with light bulbs (L510, L520, L530, L535, L610, L630), light strips (L900, L920, L930), plugs (P100, P105, P110, P110M, P115), power strips (P300, P304M, P306, P316M), hubs (H100), switches (S200B) and sensors (KE100, T100, T110, T300, T310, T315)." +readme = "README.md" +license = "MIT" +authors = [{ name = "Mihai Dinculescu", email = "mihai.dinculescu@outlook.com" }] +maintainers = [{ name = "Mihai Dinculescu", email = "mihai.dinculescu@outlook.com" }] +keywords = ["Tapo", "TP-Link", "Smart Home", "Home Automation", "IoT"] +classifiers = [ + "Development Status :: 4 - Beta", + "Topic :: Software Development :: Embedded Systems", + "Environment :: Console", + "Operating System :: OS Independent", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Topic :: Home Automation", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +requires-python = ">=3.11" + +[project.urls] +Changelog = 'https://github.com/mihai-dinculescu/tapo/blob/main/CHANGELOG.md' +Funding = 'https://github.com/mihai-dinculescu' +Homepage = 'https://github.com/mihai-dinculescu/tapo' +Source = 'https://github.com/mihai-dinculescu/tapo' + +[tool.poetry.dependencies] +python = "^3.11" + +[tool.poetry.group.dev.dependencies] +maturin = ">=1.0,<2.0" +black = ">=25.0,<26.0" + +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[tool.maturin] +python-source = "tapo-py" +bindings = 'pyo3' +features = ["pyo3/extension-module"] +include = ["README.md", "CHANGELOG.md", "LICENSE", "tapo-py/tapo-py/tapo/*"] + +[tool.black] +line-length = 100 diff --git a/agents/tapo/tapo-fork/tapo-py/src/api.rs b/agents/tapo/tapo-fork/tapo-py/src/api.rs new file mode 100644 index 0000000..c201907 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api.rs @@ -0,0 +1,29 @@ +mod api_client; +mod child_devices; +mod color_light_handler; +mod discovery; +mod generic_device_handler; +mod hub_handler; +mod light_handler; +mod plug_energy_monitoring_handler; +mod plug_handler; +mod power_strip_energy_monitoring_handler; +mod power_strip_handler; +mod py_handler_ext; +mod rgb_light_strip_handler; +mod rgbic_light_strip_handler; + +pub use api_client::*; +pub use child_devices::*; +pub use color_light_handler::*; +pub use discovery::*; +pub use generic_device_handler::*; +pub use hub_handler::*; +pub use light_handler::*; +pub use plug_energy_monitoring_handler::*; +pub use plug_handler::*; +pub use power_strip_energy_monitoring_handler::*; +pub use power_strip_handler::*; +pub use py_handler_ext::*; +pub use rgb_light_strip_handler::*; +pub use rgbic_light_strip_handler::*; diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/api_client.rs b/agents/tapo/tapo-fork/tapo-py/src/api/api_client.rs new file mode 100644 index 0000000..315884e --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/api_client.rs @@ -0,0 +1,165 @@ +use std::time::Duration; + +use pyo3::prelude::*; +use tapo::{ + ApiClient, ColorLightHandler, DeviceDiscovery, GenericDeviceHandler, HubHandler, LightHandler, + PlugEnergyMonitoringHandler, PlugHandler, PowerStripEnergyMonitoringHandler, PowerStripHandler, + RgbLightStripHandler, RgbicLightStripHandler, +}; + +use crate::call_handler_constructor; +use crate::errors::ErrorWrapper; + +use super::{ + PyColorLightHandler, PyDeviceDiscovery, PyGenericDeviceHandler, PyHubHandler, PyLightHandler, + PyPlugEnergyMonitoringHandler, PyPlugHandler, PyPowerStripEnergyMonitoringHandler, + PyPowerStripHandler, PyRgbLightStripHandler, PyRgbicLightStripHandler, +}; + +#[pyclass(name = "ApiClient")] +pub struct PyApiClient { + client: ApiClient, +} + +#[pymethods] +impl PyApiClient { + #[new] + #[pyo3(signature = (tapo_username, tapo_password, timeout_s=None))] + pub fn new( + tapo_username: String, + tapo_password: String, + timeout_s: Option, + ) -> Result { + let client = match timeout_s { + Some(timeout_s) => ApiClient::new(tapo_username, tapo_password) + .with_timeout(Duration::from_secs(timeout_s)), + None => ApiClient::new(tapo_username, tapo_password), + }; + + Ok(Self { client }) + } + + pub async fn discover_devices( + &self, + target: String, + timeout_s: u64, + ) -> Result { + let discovery: DeviceDiscovery = + call_handler_constructor!(self, tapo::ApiClient::discover_devices, target, timeout_s); + Ok(PyDeviceDiscovery::new(discovery)) + } + + pub async fn generic_device(&self, ip_address: String) -> PyResult { + let handler: GenericDeviceHandler = + call_handler_constructor!(self, tapo::ApiClient::generic_device, ip_address); + Ok(PyGenericDeviceHandler::new(handler)) + } + + pub async fn l510(&self, ip_address: String) -> PyResult { + let handler: LightHandler = + call_handler_constructor!(self, tapo::ApiClient::l510, ip_address); + Ok(PyLightHandler::new(handler)) + } + + pub async fn l520(&self, ip_address: String) -> PyResult { + let handler: LightHandler = + call_handler_constructor!(self, tapo::ApiClient::l520, ip_address); + Ok(PyLightHandler::new(handler)) + } + + pub async fn l530(&self, ip_address: String) -> PyResult { + let handler: ColorLightHandler = + call_handler_constructor!(self, tapo::ApiClient::l530, ip_address); + Ok(PyColorLightHandler::new(handler)) + } + + pub async fn l535(&self, ip_address: String) -> PyResult { + let handler: ColorLightHandler = + call_handler_constructor!(self, tapo::ApiClient::l535, ip_address); + Ok(PyColorLightHandler::new(handler)) + } + + pub async fn l610(&self, ip_address: String) -> PyResult { + let handler: LightHandler = + call_handler_constructor!(self, tapo::ApiClient::l610, ip_address); + Ok(PyLightHandler::new(handler)) + } + + pub async fn l630(&self, ip_address: String) -> PyResult { + let handler: ColorLightHandler = + call_handler_constructor!(self, tapo::ApiClient::l630, ip_address); + Ok(PyColorLightHandler::new(handler)) + } + + pub async fn l900(&self, ip_address: String) -> PyResult { + let handler: RgbLightStripHandler = + call_handler_constructor!(self, tapo::ApiClient::l900, ip_address); + Ok(PyRgbLightStripHandler::new(handler)) + } + + pub async fn l920(&self, ip_address: String) -> PyResult { + let handler: RgbicLightStripHandler = + call_handler_constructor!(self, tapo::ApiClient::l920, ip_address); + Ok(PyRgbicLightStripHandler::new(handler)) + } + + pub async fn l930(&self, ip_address: String) -> PyResult { + let handler: RgbicLightStripHandler = + call_handler_constructor!(self, tapo::ApiClient::l930, ip_address); + Ok(PyRgbicLightStripHandler::new(handler)) + } + + pub async fn p100(&self, ip_address: String) -> PyResult { + let handler: PlugHandler = + call_handler_constructor!(self, tapo::ApiClient::p100, ip_address); + Ok(PyPlugHandler::new(handler)) + } + + pub async fn p105(&self, ip_address: String) -> PyResult { + let handler: PlugHandler = + call_handler_constructor!(self, tapo::ApiClient::p105, ip_address); + Ok(PyPlugHandler::new(handler)) + } + + pub async fn p110(&self, ip_address: String) -> PyResult { + let handler: PlugEnergyMonitoringHandler = + call_handler_constructor!(self, tapo::ApiClient::p110, ip_address); + Ok(PyPlugEnergyMonitoringHandler::new(handler)) + } + + pub async fn p115(&self, ip_address: String) -> PyResult { + let handler: PlugEnergyMonitoringHandler = + call_handler_constructor!(self, tapo::ApiClient::p115, ip_address); + Ok(PyPlugEnergyMonitoringHandler::new(handler)) + } + + pub async fn p300(&self, ip_address: String) -> PyResult { + let handler: PowerStripHandler = + call_handler_constructor!(self, tapo::ApiClient::p300, ip_address); + Ok(PyPowerStripHandler::new(handler)) + } + + pub async fn p304(&self, ip_address: String) -> PyResult { + let handler: PowerStripEnergyMonitoringHandler = + call_handler_constructor!(self, tapo::ApiClient::p304, ip_address); + Ok(PyPowerStripEnergyMonitoringHandler::new(handler)) + } + + pub async fn p306(&self, ip_address: String) -> PyResult { + let handler: PowerStripHandler = + call_handler_constructor!(self, tapo::ApiClient::p306, ip_address); + Ok(PyPowerStripHandler::new(handler)) + } + + pub async fn p316(&self, ip_address: String) -> PyResult { + let handler: PowerStripEnergyMonitoringHandler = + call_handler_constructor!(self, tapo::ApiClient::p316, ip_address); + Ok(PyPowerStripEnergyMonitoringHandler::new(handler)) + } + + pub async fn h100(&self, ip_address: String) -> PyResult { + let handler: HubHandler = + call_handler_constructor!(self, tapo::ApiClient::h100, ip_address); + Ok(PyHubHandler::new(handler)) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/child_devices.rs b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices.rs new file mode 100644 index 0000000..068418f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices.rs @@ -0,0 +1,17 @@ +mod ke100_handler; +mod power_strip_plug_energy_monitoring_handler; +mod power_strip_plug_handler; +mod s200b_handler; +mod t100_handler; +mod t110_handler; +mod t300_handler; +mod t31x_handler; + +pub use ke100_handler::*; +pub use power_strip_plug_energy_monitoring_handler::*; +pub use power_strip_plug_handler::*; +pub use s200b_handler::*; +pub use t31x_handler::*; +pub use t100_handler::*; +pub use t110_handler::*; +pub use t300_handler::*; diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/ke100_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/ke100_handler.rs new file mode 100644 index 0000000..c7fc86d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/ke100_handler.rs @@ -0,0 +1,101 @@ +use std::{ops::Deref, sync::Arc}; + +use pyo3::{prelude::*, types::PyDict}; +use tapo::KE100Handler; +use tapo::responses::{KE100Result, TemperatureUnitKE100}; + +use crate::call_handler_method; + +#[derive(Clone)] +#[pyclass(name = "KE100Handler")] +pub struct PyKE100Handler { + inner: Arc, +} + +impl PyKE100Handler { + pub fn new(handler: KE100Handler) -> Self { + Self { + inner: Arc::new(handler), + } + } +} + +#[pymethods] +impl PyKE100Handler { + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), KE100Handler::get_device_info) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!(handler.deref(), KE100Handler::get_device_info_json)?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn set_child_protection(&self, on: bool) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), KE100Handler::set_child_protection, on) + } + + pub async fn set_frost_protection(&self, on: bool) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), KE100Handler::set_frost_protection, on) + } + + pub async fn set_max_control_temperature( + &self, + value: u8, + unit: TemperatureUnitKE100, + ) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + KE100Handler::set_max_control_temperature, + value, + unit + ) + } + + pub async fn set_min_control_temperature( + &self, + value: u8, + unit: TemperatureUnitKE100, + ) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + KE100Handler::set_min_control_temperature, + value, + unit + ) + } + + pub async fn set_target_temperature( + &self, + value: u8, + unit: TemperatureUnitKE100, + ) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + KE100Handler::set_target_temperature, + value, + unit + ) + } + + pub async fn set_temperature_offset( + &self, + value: i8, + unit: TemperatureUnitKE100, + ) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + KE100Handler::set_temperature_offset, + value, + unit + ) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/power_strip_plug_energy_monitoring_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/power_strip_plug_energy_monitoring_handler.rs new file mode 100644 index 0000000..afafc1b --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/power_strip_plug_energy_monitoring_handler.rs @@ -0,0 +1,132 @@ +use std::{ops::Deref, sync::Arc}; + +use chrono::{DateTime, NaiveDate, Utc}; +use pyo3::{prelude::*, types::PyDict}; +use tapo::PowerStripPlugEnergyMonitoringHandler; +use tapo::requests::{EnergyDataInterval, PowerDataInterval}; +use tapo::responses::{ + CurrentPowerResult, DeviceUsageEnergyMonitoringResult, EnergyDataResult, EnergyUsageResult, + PowerDataResult, PowerStripPlugEnergyMonitoringResult, +}; + +use crate::call_handler_method; +use crate::requests::{PyEnergyDataInterval, PyPowerDataInterval}; + +#[derive(Clone)] +#[pyclass(name = "PowerStripPlugEnergyMonitoringHandler")] +pub struct PyPowerStripPlugEnergyMonitoringHandler { + inner: Arc, +} + +impl PyPowerStripPlugEnergyMonitoringHandler { + pub fn new(handler: PowerStripPlugEnergyMonitoringHandler) -> Self { + Self { + inner: Arc::new(handler), + } + } +} + +#[pymethods] +impl PyPowerStripPlugEnergyMonitoringHandler { + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + PowerStripPlugEnergyMonitoringHandler::get_device_info + ) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.deref(), + PowerStripPlugEnergyMonitoringHandler::get_device_info_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn on(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), PowerStripPlugEnergyMonitoringHandler::on) + } + + pub async fn off(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), PowerStripPlugEnergyMonitoringHandler::off) + } + + pub async fn get_current_power(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + PowerStripPlugEnergyMonitoringHandler::get_current_power, + ) + } + + pub async fn get_device_usage(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + PowerStripPlugEnergyMonitoringHandler::get_device_usage, + ) + } + + pub async fn get_energy_usage(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + PowerStripPlugEnergyMonitoringHandler::get_energy_usage, + ) + } + + #[pyo3(signature = (interval, start_date, end_date=None))] + pub async fn get_energy_data( + &self, + interval: PyEnergyDataInterval, + start_date: NaiveDate, + end_date: Option, + ) -> PyResult { + let interval = match interval { + PyEnergyDataInterval::Hourly => EnergyDataInterval::Hourly { + start_date, + end_date: end_date.unwrap_or(start_date), + }, + PyEnergyDataInterval::Daily => EnergyDataInterval::Daily { start_date }, + PyEnergyDataInterval::Monthly => EnergyDataInterval::Monthly { start_date }, + }; + + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.deref(), + PowerStripPlugEnergyMonitoringHandler::get_energy_data, + interval + )?; + Ok(result) + } + + pub async fn get_power_data( + &self, + interval: PyPowerDataInterval, + start_date_time: DateTime, + end_date_time: DateTime, + ) -> PyResult { + let interval = match interval { + PyPowerDataInterval::Every5Minutes => PowerDataInterval::Every5Minutes { + start_date_time, + end_date_time, + }, + PyPowerDataInterval::Hourly => PowerDataInterval::Hourly { + start_date_time, + end_date_time, + }, + }; + + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.deref(), + PowerStripPlugEnergyMonitoringHandler::get_power_data, + interval + )?; + Ok(result) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/power_strip_plug_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/power_strip_plug_handler.rs new file mode 100644 index 0000000..8453beb --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/power_strip_plug_handler.rs @@ -0,0 +1,46 @@ +use std::{ops::Deref, sync::Arc}; + +use pyo3::{prelude::*, types::PyDict}; +use tapo::PowerStripPlugHandler; +use tapo::responses::PowerStripPlugResult; + +use crate::call_handler_method; + +#[derive(Clone)] +#[pyclass(name = "PowerStripPlugHandler")] +pub struct PyPowerStripPlugHandler { + inner: Arc, +} + +impl PyPowerStripPlugHandler { + pub fn new(handler: PowerStripPlugHandler) -> Self { + Self { + inner: Arc::new(handler), + } + } +} + +#[pymethods] +impl PyPowerStripPlugHandler { + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), PowerStripPlugHandler::get_device_info) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = + call_handler_method!(handler.deref(), PowerStripPlugHandler::get_device_info_json)?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn on(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), PowerStripPlugHandler::on) + } + + pub async fn off(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), PowerStripPlugHandler::off) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/s200b_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/s200b_handler.rs new file mode 100644 index 0000000..e2e25ac --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/s200b_handler.rs @@ -0,0 +1,51 @@ +use std::{ops::Deref, sync::Arc}; + +use pyo3::{prelude::*, types::PyDict}; +use tapo::S200BHandler; +use tapo::responses::S200BResult; + +use crate::call_handler_method; +use crate::responses::TriggerLogsS200BResult; + +#[derive(Clone)] +#[pyclass(name = "S200BHandler")] +pub struct PyS200BHandler { + inner: Arc, +} + +impl PyS200BHandler { + pub fn new(handler: S200BHandler) -> Self { + Self { + inner: Arc::new(handler), + } + } +} + +#[pymethods] +impl PyS200BHandler { + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), S200BHandler::get_device_info) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!(handler.deref(), S200BHandler::get_device_info_json)?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_trigger_logs( + &self, + page_size: u64, + start_id: u64, + ) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + S200BHandler::get_trigger_logs, + page_size, + start_id + ) + .map(|result| result.into()) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t100_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t100_handler.rs new file mode 100644 index 0000000..05c0186 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t100_handler.rs @@ -0,0 +1,50 @@ +use std::{ops::Deref, sync::Arc}; + +use pyo3::{prelude::*, types::PyDict}; +use tapo::{T100Handler, responses::T100Result}; + +use crate::call_handler_method; +use crate::responses::TriggerLogsT100Result; + +#[derive(Clone)] +#[pyclass(name = "T100Handler")] +pub struct PyT100Handler { + inner: Arc, +} + +impl PyT100Handler { + pub fn new(handler: T100Handler) -> Self { + Self { + inner: Arc::new(handler), + } + } +} + +#[pymethods] +impl PyT100Handler { + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), T100Handler::get_device_info) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!(handler.deref(), T100Handler::get_device_info_json)?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_trigger_logs( + &self, + page_size: u64, + start_id: u64, + ) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + T100Handler::get_trigger_logs, + page_size, + start_id + ) + .map(|result| result.into()) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t110_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t110_handler.rs new file mode 100644 index 0000000..48ed093 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t110_handler.rs @@ -0,0 +1,51 @@ +use std::{ops::Deref, sync::Arc}; + +use pyo3::{prelude::*, types::PyDict}; +use tapo::T110Handler; +use tapo::responses::T110Result; + +use crate::call_handler_method; +use crate::responses::TriggerLogsT110Result; + +#[derive(Clone)] +#[pyclass(name = "T110Handler")] +pub struct PyT110Handler { + inner: Arc, +} + +impl PyT110Handler { + pub fn new(handler: T110Handler) -> Self { + Self { + inner: Arc::new(handler), + } + } +} + +#[pymethods] +impl PyT110Handler { + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), T110Handler::get_device_info) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!(handler.deref(), T110Handler::get_device_info_json)?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_trigger_logs( + &self, + page_size: u64, + start_id: u64, + ) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + T110Handler::get_trigger_logs, + page_size, + start_id + ) + .map(|result| result.into()) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t300_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t300_handler.rs new file mode 100644 index 0000000..5cb4f36 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t300_handler.rs @@ -0,0 +1,51 @@ +use std::{ops::Deref, sync::Arc}; + +use pyo3::{prelude::*, types::PyDict}; +use tapo::T300Handler; +use tapo::responses::T300Result; + +use crate::call_handler_method; +use crate::responses::TriggerLogsT300Result; + +#[derive(Clone)] +#[pyclass(name = "T300Handler")] +pub struct PyT300Handler { + inner: Arc, +} + +impl PyT300Handler { + pub fn new(handler: T300Handler) -> Self { + Self { + inner: Arc::new(handler), + } + } +} + +#[pymethods] +impl PyT300Handler { + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), T300Handler::get_device_info) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!(handler.deref(), T300Handler::get_device_info_json)?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_trigger_logs( + &self, + page_size: u64, + start_id: u64, + ) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + T300Handler::get_trigger_logs, + page_size, + start_id + ) + .map(|result| result.into()) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t31x_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t31x_handler.rs new file mode 100644 index 0000000..4998681 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/child_devices/t31x_handler.rs @@ -0,0 +1,43 @@ +use std::{ops::Deref, sync::Arc}; + +use pyo3::{prelude::*, types::PyDict}; +use tapo::T31XHandler; +use tapo::responses::{T31XResult, TemperatureHumidityRecords}; + +use crate::call_handler_method; + +#[derive(Clone)] +#[pyclass(name = "T31XHandler")] +pub struct PyT31XHandler { + inner: Arc, +} + +impl PyT31XHandler { + pub fn new(handler: T31XHandler) -> Self { + Self { + inner: Arc::new(handler), + } + } +} + +#[pymethods] +impl PyT31XHandler { + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.deref(), T31XHandler::get_device_info) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!(handler.deref(), T31XHandler::get_device_info_json)?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_temperature_humidity_records(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.deref(), + T31XHandler::get_temperature_humidity_records + ) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/color_light_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/color_light_handler.rs new file mode 100644 index 0000000..78b8256 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/color_light_handler.rs @@ -0,0 +1,138 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use pyo3::prelude::*; +use pyo3::types::PyDict; +use tapo::requests::Color; +use tapo::responses::{DeviceInfoColorLightResult, DeviceUsageEnergyMonitoringResult}; +use tapo::{ColorLightHandler, DeviceManagementExt as _, HandlerExt}; +use tokio::sync::RwLock; + +use crate::api::PyHandlerExt; +use crate::call_handler_method; +use crate::requests::PyColorLightSetDeviceInfoParams; + +#[derive(Clone)] +#[pyclass(name = "ColorLightHandler")] +pub struct PyColorLightHandler { + inner: Arc>, +} + +impl PyColorLightHandler { + pub fn new(handler: ColorLightHandler) -> Self { + Self { + inner: Arc::new(RwLock::new(handler)), + } + } +} + +impl PyHandlerExt for PyColorLightHandler { + fn get_inner_handler(&self) -> Arc> { + Arc::clone(&self.inner) + } +} + +#[pymethods] +impl PyColorLightHandler { + pub async fn refresh_session(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.write().await.deref_mut(), + ColorLightHandler::refresh_session, + discard_result + ) + } + + pub async fn on(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), ColorLightHandler::on) + } + + pub async fn off(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), ColorLightHandler::off) + } + + pub async fn device_reboot(&self, delay_s: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + ColorLightHandler::device_reboot, + delay_s + ) + } + + pub async fn device_reset(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + ColorLightHandler::device_reset, + ) + } + + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + ColorLightHandler::get_device_info + ) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + ColorLightHandler::get_device_info_json, + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_device_usage(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + ColorLightHandler::get_device_usage + ) + } + + pub fn set(&self) -> PyColorLightSetDeviceInfoParams { + PyColorLightSetDeviceInfoParams::new() + } + + pub async fn set_brightness(&self, brightness: u8) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + ColorLightHandler::set_brightness, + brightness + ) + } + + pub async fn set_color(&self, color: Color) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + ColorLightHandler::set_color, + color + ) + } + + pub async fn set_hue_saturation(&self, hue: u16, saturation: u8) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + ColorLightHandler::set_hue_saturation, + hue, + saturation + ) + } + + pub async fn set_color_temperature(&self, color_temperature: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + ColorLightHandler::set_color_temperature, + color_temperature + ) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/discovery.rs b/agents/tapo/tapo-fork/tapo-py/src/api/discovery.rs new file mode 100644 index 0000000..b5d93ac --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/discovery.rs @@ -0,0 +1,5 @@ +mod device_discovery; +mod discovery_result; + +pub use device_discovery::*; +pub use discovery_result::*; diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/discovery/device_discovery.rs b/agents/tapo/tapo-fork/tapo-py/src/api/discovery/device_discovery.rs new file mode 100644 index 0000000..b11189b --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/discovery/device_discovery.rs @@ -0,0 +1,87 @@ +use std::sync::Arc; + +use pyo3::prelude::*; +use tapo::{DeviceDiscovery, StreamExt as _}; +use tokio::sync::Mutex; + +use super::{PyMaybeDiscoveryResult, convert_result_to_maybe_py}; + +#[derive(Clone)] +#[pyclass(name = "DeviceDiscovery")] +pub struct PyDeviceDiscovery { + pub inner: Arc>, +} + +impl PyDeviceDiscovery { + pub fn new(inner: DeviceDiscovery) -> Self { + Self { + inner: Arc::new(Mutex::new(inner)), + } + } +} + +#[pymethods] +impl PyDeviceDiscovery { + fn __iter__(slf: PyRef<'_, Self>) -> PyResult { + Ok(PyDeviceDiscoveryIter { + inner: (*slf).inner.clone(), + }) + } + fn __aiter__(slf: PyRef<'_, Self>) -> PyResult { + Ok(PyDeviceDiscoveryIter { + inner: (*slf).inner.clone(), + }) + } +} + +#[pyclass(name = "DeviceDiscoveryIter")] +pub struct PyDeviceDiscoveryIter { + pub inner: Arc>, +} + +#[pymethods] +impl PyDeviceDiscoveryIter { + fn __iter__(slf: Py) -> Py { + slf + } + + fn __aiter__(slf: Py) -> Py { + slf + } + + fn __next__(slf: PyRefMut<'_, Self>) -> PyResult> { + let inner = (*slf).inner.clone(); + + let result = Python::attach(|py| { + py.detach(|| { + crate::runtime::tokio().block_on(async { + let mut guard = inner.lock().await; + guard.next().await + }) + }) + }); + + if let Some(result) = result { + let result_maybe_py = convert_result_to_maybe_py(result)?; + Ok(Some(result_maybe_py)) + } else { + Ok(None) + } + } + + fn __anext__<'py>(slf: PyRefMut<'_, Self>, py: Python<'py>) -> PyResult> { + let inner = (*slf).inner.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = inner.lock().await; + let result = guard.next().await; + + match result { + Some(result) => convert_result_to_maybe_py(result), + None => Err(PyErr::new::( + "No more devices found", + )), + } + }) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/discovery/discovery_result.rs b/agents/tapo/tapo-fork/tapo-py/src/api/discovery/discovery_result.rs new file mode 100644 index 0000000..8f364a7 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/discovery/discovery_result.rs @@ -0,0 +1,170 @@ +use pyo3::prelude::*; +use tapo::responses::{ + DeviceInfoColorLightResult, DeviceInfoGenericResult, DeviceInfoHubResult, + DeviceInfoLightResult, DeviceInfoPlugEnergyMonitoringResult, DeviceInfoPlugResult, + DeviceInfoPowerStripResult, DeviceInfoRgbLightStripResult, DeviceInfoRgbicLightStripResult, +}; +use tapo::{DiscoveryResult, Error}; + +use crate::api::{ + PyColorLightHandler, PyGenericDeviceHandler, PyHubHandler, PyLightHandler, + PyPlugEnergyMonitoringHandler, PyPlugHandler, PyPowerStripEnergyMonitoringHandler, + PyPowerStripHandler, PyRgbLightStripHandler, PyRgbicLightStripHandler, +}; +use crate::errors::ErrorWrapper; + +#[pyclass(name = "DiscoveryResult")] +#[allow(clippy::large_enum_variant)] +pub enum PyDiscoveryResult { + GenericDevice { + device_info: DeviceInfoGenericResult, + handler: PyGenericDeviceHandler, + }, + Light { + device_info: DeviceInfoLightResult, + handler: PyLightHandler, + }, + ColorLight { + device_info: DeviceInfoColorLightResult, + handler: PyColorLightHandler, + }, + RgbLightStrip { + device_info: DeviceInfoRgbLightStripResult, + handler: PyRgbLightStripHandler, + }, + RgbicLightStrip { + device_info: DeviceInfoRgbicLightStripResult, + handler: PyRgbicLightStripHandler, + }, + Plug { + device_info: DeviceInfoPlugResult, + handler: PyPlugHandler, + }, + PlugEnergyMonitoring { + device_info: DeviceInfoPlugEnergyMonitoringResult, + handler: PyPlugEnergyMonitoringHandler, + }, + PowerStrip { + device_info: DeviceInfoPowerStripResult, + handler: PyPowerStripHandler, + }, + PowerStripEnergyMonitoring { + device_info: DeviceInfoPowerStripResult, + handler: PyPowerStripEnergyMonitoringHandler, + }, + Hub { + device_info: DeviceInfoHubResult, + handler: PyHubHandler, + }, +} + +#[pyclass(name = "MaybeDiscoveryResult")] +pub struct PyMaybeDiscoveryResult { + result: Option, + exception: Option, +} + +#[pymethods] +impl PyMaybeDiscoveryResult { + pub fn get(mut slf: PyRefMut<'_, Self>) -> PyResult { + if let Some(result) = slf.result.take() { + Ok(result) + } else if let Some(exception) = slf.exception.take() { + Err(exception.into()) + } else { + Err(PyErr::new::( + "No result or exception available. `get` can only be called once.", + )) + } + } +} + +pub fn convert_result_to_maybe_py( + result: Result, +) -> PyResult { + match result { + Ok(result) => Ok(PyMaybeDiscoveryResult { + result: Some(convert_result_to_py(result)), + exception: None, + }), + Err(e) => Ok(PyMaybeDiscoveryResult { + result: None, + exception: Some(ErrorWrapper::from(e)), + }), + } +} + +fn convert_result_to_py(result: DiscoveryResult) -> PyDiscoveryResult { + match result { + DiscoveryResult::GenericDevice { + device_info, + handler, + } => PyDiscoveryResult::GenericDevice { + device_info: *device_info, + handler: PyGenericDeviceHandler::new(handler), + }, + DiscoveryResult::Light { + device_info, + handler, + } => PyDiscoveryResult::Light { + device_info: *device_info, + handler: PyLightHandler::new(handler), + }, + DiscoveryResult::ColorLight { + device_info, + handler, + } => PyDiscoveryResult::ColorLight { + device_info: *device_info, + handler: PyColorLightHandler::new(handler), + }, + DiscoveryResult::RgbLightStrip { + device_info, + handler, + } => PyDiscoveryResult::RgbLightStrip { + device_info: *device_info, + handler: PyRgbLightStripHandler::new(handler), + }, + DiscoveryResult::RgbicLightStrip { + device_info, + handler, + } => PyDiscoveryResult::RgbicLightStrip { + device_info: *device_info, + handler: PyRgbicLightStripHandler::new(handler), + }, + DiscoveryResult::Plug { + device_info, + handler, + } => PyDiscoveryResult::Plug { + device_info: *device_info, + handler: PyPlugHandler::new(handler), + }, + DiscoveryResult::PlugEnergyMonitoring { + device_info, + handler, + } => PyDiscoveryResult::PlugEnergyMonitoring { + device_info: *device_info, + handler: PyPlugEnergyMonitoringHandler::new(handler), + }, + DiscoveryResult::PowerStrip { + device_info, + handler, + } => PyDiscoveryResult::PowerStrip { + device_info: *device_info, + handler: PyPowerStripHandler::new(handler), + }, + DiscoveryResult::PowerStripEnergyMonitoring { + device_info, + handler, + } => PyDiscoveryResult::PowerStripEnergyMonitoring { + device_info: *device_info, + handler: PyPowerStripEnergyMonitoringHandler::new(handler), + }, + DiscoveryResult::Hub { + device_info, + handler, + } => PyDiscoveryResult::Hub { + device_info: *device_info, + handler: PyHubHandler::new(handler), + }, + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/generic_device_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/generic_device_handler.rs new file mode 100644 index 0000000..c9c623e --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/generic_device_handler.rs @@ -0,0 +1,63 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use pyo3::prelude::*; +use pyo3::types::PyDict; +use tapo::GenericDeviceHandler; +use tapo::responses::DeviceInfoGenericResult; +use tokio::sync::RwLock; + +use crate::call_handler_method; + +#[derive(Clone)] +#[pyclass(name = "GenericDeviceHandler")] +pub struct PyGenericDeviceHandler { + inner: Arc>, +} + +impl PyGenericDeviceHandler { + pub fn new(handler: GenericDeviceHandler) -> Self { + Self { + inner: Arc::new(RwLock::new(handler)), + } + } +} + +#[pymethods] +impl PyGenericDeviceHandler { + pub async fn refresh_session(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.write().await.deref_mut(), + GenericDeviceHandler::refresh_session, + discard_result + ) + } + + pub async fn on(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), GenericDeviceHandler::on) + } + + pub async fn off(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), GenericDeviceHandler::off) + } + + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + GenericDeviceHandler::get_device_info + ) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + GenericDeviceHandler::get_device_info_json, + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/hub_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/hub_handler.rs new file mode 100644 index 0000000..99995da --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/hub_handler.rs @@ -0,0 +1,291 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; +use tapo::requests::{AlarmDuration, AlarmRingtone, AlarmVolume}; +use tapo::responses::{ChildDeviceHubResult, DeviceInfoHubResult}; +use tapo::{DeviceManagementExt as _, Error, HubDevice, HubHandler}; +use tokio::sync::RwLock; + +use crate::api::{ + PyKE100Handler, PyS200BHandler, PyT31XHandler, PyT100Handler, PyT110Handler, PyT300Handler, +}; +use crate::call_handler_method; +use crate::errors::ErrorWrapper; +use crate::requests::PyAlarmDuration; + +#[derive(Clone)] +#[pyclass(name = "HubHandler")] +pub struct PyHubHandler { + inner: Arc>, +} + +impl PyHubHandler { + pub fn new(handler: HubHandler) -> Self { + Self { + inner: Arc::new(RwLock::new(handler)), + } + } + + fn parse_identifier( + device_id: Option, + nickname: Option, + ) -> PyResult { + match (device_id, nickname) { + (Some(device_id), _) => Ok(HubDevice::ByDeviceId(device_id)), + (None, Some(nickname)) => Ok(HubDevice::ByNickname(nickname)), + _ => Err(Into::::into(Error::Validation { + field: "identifier".to_string(), + message: "Either a device_id or nickname must be provided".to_string(), + }) + .into()), + } + } +} + +#[pymethods] +impl PyHubHandler { + pub async fn refresh_session(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.write().await.deref_mut(), + HubHandler::refresh_session, + discard_result + ) + } + + pub async fn device_reboot(&self, delay_s: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + HubHandler::device_reboot, + delay_s + ) + } + + pub async fn device_reset(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), HubHandler::device_reset) + } + + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), HubHandler::get_device_info) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + HubHandler::get_device_info_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_child_device_list(&self) -> PyResult> { + let handler = self.inner.clone(); + let children = call_handler_method!( + handler.read().await.deref(), + HubHandler::get_child_device_list + )?; + + Python::attach(|py| { + let results = PyList::empty(py); + + for child in children { + match child { + ChildDeviceHubResult::KE100(device) => { + results.append(device.into_pyobject(py)?)?; + } + ChildDeviceHubResult::S200B(device) => { + results.append(device.into_pyobject(py)?)?; + } + ChildDeviceHubResult::T100(device) => { + results.append(device.into_pyobject(py)?)?; + } + ChildDeviceHubResult::T110(device) => { + results.append(device.into_pyobject(py)?)?; + } + ChildDeviceHubResult::T300(device) => { + results.append(device.into_pyobject(py)?)?; + } + ChildDeviceHubResult::T310(device) => { + results.append(device.into_pyobject(py)?)?; + } + ChildDeviceHubResult::T315(device) => { + results.append(device.into_pyobject(py)?)?; + } + _ => { + results.append(py.None())?; + } + } + } + + Ok(results.into()) + }) + } + + pub async fn get_child_device_list_json(&self, start_index: u64) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + HubHandler::get_child_device_list_json, + start_index + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_child_device_component_list_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + HubHandler::get_child_device_component_list_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_supported_ringtone_list(&self) -> PyResult> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + HubHandler::get_supported_ringtone_list + ) + } + + #[pyo3(signature = (ringtone, volume, duration, seconds=None))] + pub async fn play_alarm( + &self, + ringtone: AlarmRingtone, + volume: AlarmVolume, + duration: PyAlarmDuration, + seconds: Option, + ) -> PyResult<()> { + let handler = self.inner.clone(); + + let duration = match duration { + PyAlarmDuration::Continuous => AlarmDuration::Continuous, + PyAlarmDuration::Once => AlarmDuration::Once, + PyAlarmDuration::Seconds => { + if let Some(seconds) = seconds { + AlarmDuration::Seconds(seconds) + } else { + return Err(Into::::into(Error::Validation { + field: "seconds".to_string(), + message: + "A value must be provided for seconds when duration = AlarmDuration.Seconds" + .to_string(), + }) + .into()); + } + } + }; + + call_handler_method!( + handler.read().await.deref(), + HubHandler::play_alarm, + ringtone, + volume, + duration + ) + } + + pub async fn stop_alarm(&self) -> PyResult<()> { + let handler = self.inner.clone(); + + call_handler_method!(handler.read().await.deref(), HubHandler::stop_alarm) + } + + #[pyo3(signature = (device_id=None, nickname=None))] + pub async fn ke100( + &self, + device_id: Option, + nickname: Option, + ) -> PyResult { + let handler = self.inner.clone(); + let identifier = PyHubHandler::parse_identifier(device_id, nickname)?; + + let child_handler = + call_handler_method!(handler.read().await.deref(), HubHandler::ke100, identifier)?; + Ok(PyKE100Handler::new(child_handler)) + } + + #[pyo3(signature = (device_id=None, nickname=None))] + pub async fn s200b( + &self, + device_id: Option, + nickname: Option, + ) -> PyResult { + let handler = self.inner.clone(); + let identifier = PyHubHandler::parse_identifier(device_id, nickname)?; + + let child_handler = + call_handler_method!(handler.read().await.deref(), HubHandler::s200b, identifier)?; + Ok(PyS200BHandler::new(child_handler)) + } + + #[pyo3(signature = (device_id=None, nickname=None))] + pub async fn t100( + &self, + device_id: Option, + nickname: Option, + ) -> PyResult { + let handler = self.inner.clone(); + let identifier = PyHubHandler::parse_identifier(device_id, nickname)?; + + let child_handler = + call_handler_method!(handler.read().await.deref(), HubHandler::t100, identifier)?; + Ok(PyT100Handler::new(child_handler)) + } + + #[pyo3(signature = (device_id=None, nickname=None))] + pub async fn t110( + &self, + device_id: Option, + nickname: Option, + ) -> PyResult { + let handler = self.inner.clone(); + let identifier = PyHubHandler::parse_identifier(device_id, nickname)?; + + let child_handler = + call_handler_method!(handler.read().await.deref(), HubHandler::t110, identifier)?; + Ok(PyT110Handler::new(child_handler)) + } + + #[pyo3(signature = (device_id=None, nickname=None))] + pub async fn t300( + &self, + device_id: Option, + nickname: Option, + ) -> PyResult { + let handler = self.inner.clone(); + let identifier = PyHubHandler::parse_identifier(device_id, nickname)?; + + let child_handler = + call_handler_method!(handler.read().await.deref(), HubHandler::t300, identifier)?; + Ok(PyT300Handler::new(child_handler)) + } + + #[pyo3(signature = (device_id=None, nickname=None))] + pub async fn t310( + &self, + device_id: Option, + nickname: Option, + ) -> PyResult { + let handler = self.inner.clone(); + let identifier = PyHubHandler::parse_identifier(device_id, nickname)?; + + let child_handler = + call_handler_method!(handler.read().await.deref(), HubHandler::t310, identifier)?; + Ok(PyT31XHandler::new(child_handler)) + } + + #[pyo3(signature = (device_id=None, nickname=None))] + pub async fn t315( + &self, + device_id: Option, + nickname: Option, + ) -> PyResult { + self.t310(device_id, nickname).await + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/light_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/light_handler.rs new file mode 100644 index 0000000..0744b44 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/light_handler.rs @@ -0,0 +1,88 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use pyo3::prelude::*; +use pyo3::types::PyDict; +use tapo::responses::{DeviceInfoLightResult, DeviceUsageEnergyMonitoringResult}; +use tapo::{DeviceManagementExt as _, LightHandler}; +use tokio::sync::RwLock; + +use crate::call_handler_method; + +#[derive(Clone)] +#[pyclass(name = "LightHandler")] +pub struct PyLightHandler { + inner: Arc>, +} + +impl PyLightHandler { + pub fn new(handler: LightHandler) -> Self { + Self { + inner: Arc::new(RwLock::new(handler)), + } + } +} + +#[pymethods] +impl PyLightHandler { + pub async fn refresh_session(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.write().await.deref_mut(), + LightHandler::refresh_session, + discard_result + ) + } + + pub async fn on(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), LightHandler::on) + } + + pub async fn off(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), LightHandler::off) + } + + pub async fn device_reboot(&self, delay_s: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + LightHandler::device_reboot, + delay_s + ) + } + + pub async fn device_reset(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), LightHandler::device_reset) + } + + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), LightHandler::get_device_info) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + LightHandler::get_device_info_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_device_usage(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), LightHandler::get_device_usage) + } + + pub async fn set_brightness(&self, brightness: u8) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + LightHandler::set_brightness, + brightness + ) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/plug_energy_monitoring_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/plug_energy_monitoring_handler.rs new file mode 100644 index 0000000..1c618a9 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/plug_energy_monitoring_handler.rs @@ -0,0 +1,167 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use chrono::{DateTime, NaiveDate, Utc}; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use tapo::requests::{EnergyDataInterval, PowerDataInterval}; +use tapo::responses::{ + CurrentPowerResult, DeviceInfoPlugEnergyMonitoringResult, DeviceUsageEnergyMonitoringResult, + EnergyDataResult, EnergyUsageResult, PowerDataResult, +}; +use tapo::{DeviceManagementExt as _, PlugEnergyMonitoringHandler}; +use tokio::sync::RwLock; + +use crate::call_handler_method; +use crate::requests::{PyEnergyDataInterval, PyPowerDataInterval}; + +#[derive(Clone)] +#[pyclass(name = "PlugEnergyMonitoringHandler")] +pub struct PyPlugEnergyMonitoringHandler { + inner: Arc>, +} + +impl PyPlugEnergyMonitoringHandler { + pub fn new(handler: PlugEnergyMonitoringHandler) -> Self { + Self { + inner: Arc::new(RwLock::new(handler)), + } + } +} + +#[pymethods] +impl PyPlugEnergyMonitoringHandler { + pub async fn refresh_session(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.write().await.deref_mut(), + PlugEnergyMonitoringHandler::refresh_session, + discard_result + ) + } + + pub async fn on(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::on + ) + } + + pub async fn off(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::off + ) + } + + pub async fn device_reboot(&self, delay_s: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::device_reboot, + delay_s + ) + } + + pub async fn device_reset(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::device_reset, + ) + } + + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::get_device_info, + ) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::get_device_info_json, + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_current_power(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::get_current_power, + ) + } + + pub async fn get_device_usage(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::get_device_usage, + ) + } + + pub async fn get_energy_usage(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::get_energy_usage, + ) + } + + #[pyo3(signature = (interval, start_date, end_date=None))] + pub async fn get_energy_data( + &self, + interval: PyEnergyDataInterval, + start_date: NaiveDate, + end_date: Option, + ) -> PyResult { + let interval = match interval { + PyEnergyDataInterval::Hourly => EnergyDataInterval::Hourly { + start_date, + end_date: end_date.unwrap_or(start_date), + }, + PyEnergyDataInterval::Daily => EnergyDataInterval::Daily { start_date }, + PyEnergyDataInterval::Monthly => EnergyDataInterval::Monthly { start_date }, + }; + + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::get_energy_data, + interval + )?; + Ok(result) + } + + pub async fn get_power_data( + &self, + interval: PyPowerDataInterval, + start_date_time: DateTime, + end_date_time: DateTime, + ) -> PyResult { + let interval = match interval { + PyPowerDataInterval::Every5Minutes => PowerDataInterval::Every5Minutes { + start_date_time, + end_date_time, + }, + PyPowerDataInterval::Hourly => PowerDataInterval::Hourly { + start_date_time, + end_date_time, + }, + }; + + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + PlugEnergyMonitoringHandler::get_power_data, + interval + )?; + Ok(result) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/plug_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/plug_handler.rs new file mode 100644 index 0000000..0324563 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/plug_handler.rs @@ -0,0 +1,79 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use pyo3::prelude::*; +use pyo3::types::PyDict; +use tapo::responses::{DeviceInfoPlugResult, DeviceUsageResult}; +use tapo::{DeviceManagementExt as _, PlugHandler}; +use tokio::sync::RwLock; + +use crate::call_handler_method; + +#[derive(Clone)] +#[pyclass(name = "PlugHandler")] +pub struct PyPlugHandler { + inner: Arc>, +} + +impl PyPlugHandler { + pub fn new(handler: PlugHandler) -> Self { + Self { + inner: Arc::new(RwLock::new(handler)), + } + } +} + +#[pymethods] +impl PyPlugHandler { + pub async fn refresh_session(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.write().await.deref_mut(), + PlugHandler::refresh_session, + discard_result + ) + } + + pub async fn on(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), PlugHandler::on) + } + + pub async fn off(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), PlugHandler::off) + } + + pub async fn device_reboot(&self, delay_s: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PlugHandler::device_reboot, + delay_s + ) + } + + pub async fn device_reset(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), PlugHandler::device_reset) + } + + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), PlugHandler::get_device_info) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + PlugHandler::get_device_info_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_device_usage(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), PlugHandler::get_device_usage) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/power_strip_energy_monitoring_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/power_strip_energy_monitoring_handler.rs new file mode 100644 index 0000000..40453fe --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/power_strip_energy_monitoring_handler.rs @@ -0,0 +1,144 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; +use tapo::responses::DeviceInfoPowerStripResult; +use tapo::{DeviceManagementExt as _, Error, Plug, PowerStripEnergyMonitoringHandler}; +use tokio::sync::RwLock; + +use crate::api::PyPowerStripPlugEnergyMonitoringHandler; +use crate::call_handler_method; +use crate::errors::ErrorWrapper; + +#[derive(Clone)] +#[pyclass(name = "PowerStripEnergyMonitoringHandler")] +pub struct PyPowerStripEnergyMonitoringHandler { + inner: Arc>, +} + +impl PyPowerStripEnergyMonitoringHandler { + pub fn new(handler: PowerStripEnergyMonitoringHandler) -> Self { + Self { + inner: Arc::new(RwLock::new(handler)), + } + } + + fn parse_identifier( + device_id: Option, + nickname: Option, + position: Option, + ) -> PyResult { + match (device_id, nickname, position) { + (Some(device_id), _, _) => Ok(Plug::ByDeviceId(device_id)), + (None, Some(nickname), _) => Ok(Plug::ByNickname(nickname)), + (None, None, Some(position)) => Ok(Plug::ByPosition(position)), + _ => Err(Into::::into(Error::Validation { + field: "identifier".to_string(), + message: "Either a device_id, nickname, or position must be provided".to_string(), + }) + .into()), + } + } +} + +#[pymethods] +impl PyPowerStripEnergyMonitoringHandler { + pub async fn refresh_session(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.write().await.deref_mut(), + PowerStripEnergyMonitoringHandler::refresh_session, + discard_result + ) + } + + pub async fn device_reboot(&self, delay_s: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PowerStripEnergyMonitoringHandler::device_reboot, + delay_s + ) + } + + pub async fn device_reset(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PowerStripEnergyMonitoringHandler::device_reset, + ) + } + + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PowerStripEnergyMonitoringHandler::get_device_info + ) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + PowerStripEnergyMonitoringHandler::get_device_info_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_child_device_list(&self) -> PyResult> { + let handler = self.inner.clone(); + let children = call_handler_method!( + handler.read().await.deref(), + PowerStripEnergyMonitoringHandler::get_child_device_list + )?; + + Python::attach(|py| { + let results = PyList::empty(py); + + for child in children { + results.append(child.into_pyobject(py)?)?; + } + + Ok(results.into()) + }) + } + + pub async fn get_child_device_list_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + PowerStripEnergyMonitoringHandler::get_child_device_list_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_child_device_component_list_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + PowerStripEnergyMonitoringHandler::get_child_device_component_list_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + #[pyo3(signature = (device_id=None, nickname=None, position=None))] + pub async fn plug( + &self, + device_id: Option, + nickname: Option, + position: Option, + ) -> PyResult { + let handler = self.inner.clone(); + let identifier = + PyPowerStripEnergyMonitoringHandler::parse_identifier(device_id, nickname, position)?; + + let child_handler = call_handler_method!( + handler.read().await.deref(), + PowerStripEnergyMonitoringHandler::plug, + identifier + )?; + Ok(PyPowerStripPlugEnergyMonitoringHandler::new(child_handler)) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/power_strip_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/power_strip_handler.rs new file mode 100644 index 0000000..a6785db --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/power_strip_handler.rs @@ -0,0 +1,143 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; +use tapo::responses::DeviceInfoPowerStripResult; +use tapo::{DeviceManagementExt as _, Error, Plug, PowerStripHandler}; +use tokio::sync::RwLock; + +use crate::api::PyPowerStripPlugHandler; +use crate::call_handler_method; +use crate::errors::ErrorWrapper; + +#[derive(Clone)] +#[pyclass(name = "PowerStripHandler")] +pub struct PyPowerStripHandler { + inner: Arc>, +} + +impl PyPowerStripHandler { + pub fn new(handler: PowerStripHandler) -> Self { + Self { + inner: Arc::new(RwLock::new(handler)), + } + } + + fn parse_identifier( + device_id: Option, + nickname: Option, + position: Option, + ) -> PyResult { + match (device_id, nickname, position) { + (Some(device_id), _, _) => Ok(Plug::ByDeviceId(device_id)), + (None, Some(nickname), _) => Ok(Plug::ByNickname(nickname)), + (None, None, Some(position)) => Ok(Plug::ByPosition(position)), + _ => Err(Into::::into(Error::Validation { + field: "identifier".to_string(), + message: "Either a device_id, nickname, or position must be provided".to_string(), + }) + .into()), + } + } +} + +#[pymethods] +impl PyPowerStripHandler { + pub async fn refresh_session(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.write().await.deref_mut(), + PowerStripHandler::refresh_session, + discard_result + ) + } + + pub async fn device_reboot(&self, delay_s: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PowerStripHandler::device_reboot, + delay_s + ) + } + + pub async fn device_reset(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PowerStripHandler::device_reset, + ) + } + + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + PowerStripHandler::get_device_info + ) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + PowerStripHandler::get_device_info_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_child_device_list(&self) -> PyResult> { + let handler = self.inner.clone(); + let children = call_handler_method!( + handler.read().await.deref(), + PowerStripHandler::get_child_device_list + )?; + + Python::attach(|py| { + let results = PyList::empty(py); + + for child in children { + results.append(child.into_pyobject(py)?)?; + } + + Ok(results.into()) + }) + } + + pub async fn get_child_device_list_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + PowerStripHandler::get_child_device_list_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_child_device_component_list_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + PowerStripHandler::get_child_device_component_list_json + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + #[pyo3(signature = (device_id=None, nickname=None, position=None))] + pub async fn plug( + &self, + device_id: Option, + nickname: Option, + position: Option, + ) -> PyResult { + let handler = self.inner.clone(); + let identifier = PyPowerStripHandler::parse_identifier(device_id, nickname, position)?; + + let child_handler = call_handler_method!( + handler.read().await.deref(), + PowerStripHandler::plug, + identifier + )?; + Ok(PyPowerStripPlugHandler::new(child_handler)) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/py_handler_ext.rs b/agents/tapo/tapo-fork/tapo-py/src/api/py_handler_ext.rs new file mode 100644 index 0000000..4f2d5e7 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/py_handler_ext.rs @@ -0,0 +1,8 @@ +use std::sync::Arc; + +use tapo::HandlerExt; +use tokio::sync::RwLock; + +pub trait PyHandlerExt: Send + Sync + Sized { + fn get_inner_handler(&self) -> Arc>; +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/rgb_light_strip_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/rgb_light_strip_handler.rs new file mode 100644 index 0000000..25543c0 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/rgb_light_strip_handler.rs @@ -0,0 +1,138 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use pyo3::prelude::*; +use pyo3::types::PyDict; +use tapo::requests::Color; +use tapo::responses::{DeviceInfoRgbLightStripResult, DeviceUsageEnergyMonitoringResult}; +use tapo::{DeviceManagementExt as _, HandlerExt, RgbLightStripHandler}; +use tokio::sync::RwLock; + +use crate::api::PyHandlerExt; +use crate::call_handler_method; +use crate::requests::PyColorLightSetDeviceInfoParams; + +#[derive(Clone)] +#[pyclass(name = "RgbLightStripHandler")] +pub struct PyRgbLightStripHandler { + pub inner: Arc>, +} + +impl PyRgbLightStripHandler { + pub fn new(handler: RgbLightStripHandler) -> Self { + Self { + inner: Arc::new(RwLock::new(handler)), + } + } +} + +impl PyHandlerExt for PyRgbLightStripHandler { + fn get_inner_handler(&self) -> Arc> { + Arc::clone(&self.inner) + } +} + +#[pymethods] +impl PyRgbLightStripHandler { + pub async fn refresh_session(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.write().await.deref_mut(), + RgbLightStripHandler::refresh_session, + discard_result + ) + } + + pub async fn on(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), RgbLightStripHandler::on) + } + + pub async fn off(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), RgbLightStripHandler::off) + } + + pub async fn device_reboot(&self, delay_s: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbLightStripHandler::device_reboot, + delay_s + ) + } + + pub async fn device_reset(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbLightStripHandler::device_reset, + ) + } + + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbLightStripHandler::get_device_info + ) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + RgbLightStripHandler::get_device_info_json, + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_device_usage(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbLightStripHandler::get_device_usage + ) + } + + pub fn set(&self) -> PyColorLightSetDeviceInfoParams { + PyColorLightSetDeviceInfoParams::new() + } + + pub async fn set_brightness(&self, brightness: u8) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbLightStripHandler::set_brightness, + brightness + ) + } + + pub async fn set_color(&self, color: Color) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbLightStripHandler::set_color, + color + ) + } + + pub async fn set_hue_saturation(&self, hue: u16, saturation: u8) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbLightStripHandler::set_hue_saturation, + hue, + saturation + ) + } + + pub async fn set_color_temperature(&self, color_temperature: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbLightStripHandler::set_color_temperature, + color_temperature + ) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/api/rgbic_light_strip_handler.rs b/agents/tapo/tapo-fork/tapo-py/src/api/rgbic_light_strip_handler.rs new file mode 100644 index 0000000..8915e2d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/api/rgbic_light_strip_handler.rs @@ -0,0 +1,167 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use pyo3::exceptions::PyTypeError; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use tapo::requests::{Color, LightingEffect, LightingEffectPreset}; +use tapo::responses::{DeviceInfoRgbicLightStripResult, DeviceUsageEnergyMonitoringResult}; +use tapo::{DeviceManagementExt as _, HandlerExt, RgbicLightStripHandler}; +use tokio::sync::RwLock; + +use crate::api::PyHandlerExt; +use crate::call_handler_method; +use crate::requests::{PyColorLightSetDeviceInfoParams, PyLightingEffect}; + +#[derive(Clone)] +#[pyclass(name = "RgbicLightStripHandler")] +pub struct PyRgbicLightStripHandler { + pub inner: Arc>, +} + +impl PyRgbicLightStripHandler { + pub fn new(handler: RgbicLightStripHandler) -> Self { + Self { + inner: Arc::new(RwLock::new(handler)), + } + } +} + +impl PyHandlerExt for PyRgbicLightStripHandler { + fn get_inner_handler(&self) -> Arc> { + Arc::clone(&self.inner) + } +} + +#[pymethods] +impl PyRgbicLightStripHandler { + pub async fn refresh_session(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.write().await.deref_mut(), + RgbicLightStripHandler::refresh_session, + discard_result + ) + } + + pub async fn on(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), RgbicLightStripHandler::on) + } + + pub async fn off(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!(handler.read().await.deref(), RgbicLightStripHandler::off) + } + + pub async fn device_reboot(&self, delay_s: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbicLightStripHandler::device_reboot, + delay_s + ) + } + + pub async fn device_reset(&self) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbicLightStripHandler::device_reset, + ) + } + + pub async fn get_device_info(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbicLightStripHandler::get_device_info + ) + } + + pub async fn get_device_info_json(&self) -> PyResult> { + let handler = self.inner.clone(); + let result = call_handler_method!( + handler.read().await.deref(), + RgbicLightStripHandler::get_device_info_json, + )?; + Python::attach(|py| tapo::python::serde_object_to_py_dict(py, &result)) + } + + pub async fn get_device_usage(&self) -> PyResult { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbicLightStripHandler::get_device_usage + ) + } + + pub fn set(&self) -> PyColorLightSetDeviceInfoParams { + PyColorLightSetDeviceInfoParams::new() + } + + pub async fn set_brightness(&self, brightness: u8) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbicLightStripHandler::set_brightness, + brightness + ) + } + + pub async fn set_color(&self, color: Color) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbicLightStripHandler::set_color, + color + ) + } + + pub async fn set_hue_saturation(&self, hue: u16, saturation: u8) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbicLightStripHandler::set_hue_saturation, + hue, + saturation + ) + } + + pub async fn set_color_temperature(&self, color_temperature: u16) -> PyResult<()> { + let handler = self.inner.clone(); + call_handler_method!( + handler.read().await.deref(), + RgbicLightStripHandler::set_color_temperature, + color_temperature + ) + } + + pub async fn set_lighting_effect(&self, lighting_effect: Py) -> PyResult<()> { + let handler = self.inner.clone(); + let lighting_effect = map_lighting_effect(lighting_effect)?; + call_handler_method!( + handler.read().await.deref(), + RgbicLightStripHandler::set_lighting_effect, + lighting_effect + ) + } +} + +fn map_lighting_effect(lighting_effect: Py) -> PyResult { + if let Some(lighting_effect) = + Python::attach(|py| lighting_effect.extract::(py).ok()) + { + return Ok(lighting_effect.into()); + } + + if let Some(lighting_effect) = + Python::attach(|py| lighting_effect.extract::(py).ok()) + { + return Ok(lighting_effect.into()); + } + + Err(PyErr::new::( + "Invalid lighting effect type. Must be one of `LightingEffect` or `LightingEffectPreset`", + )) +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/errors.rs b/agents/tapo/tapo-fork/tapo-py/src/errors.rs new file mode 100644 index 0000000..e8aff62 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/errors.rs @@ -0,0 +1,23 @@ +use pyo3::PyErr; +use pyo3::exceptions::PyException; +use tapo::Error; + +pub struct ErrorWrapper(pub Error); + +impl From for ErrorWrapper { + fn from(err: Error) -> Self { + Self(err) + } +} + +impl From for ErrorWrapper { + fn from(err: anyhow::Error) -> Self { + Self(err.into()) + } +} + +impl From for PyErr { + fn from(err: ErrorWrapper) -> PyErr { + PyException::new_err(format!("{:?}", err.0)) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/lib.rs b/agents/tapo/tapo-fork/tapo-py/src/lib.rs new file mode 100644 index 0000000..981736c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/lib.rs @@ -0,0 +1,209 @@ +mod api; +mod errors; +mod requests; +mod responses; +mod runtime; + +use log::LevelFilter; +use pyo3::prelude::*; +use pyo3_log::{Caching, Logger}; + +use tapo::requests::{AlarmRingtone, AlarmVolume, Color, LightingEffectPreset, LightingEffectType}; +use tapo::responses::{ + AutoOffStatus, ColorLightState, CurrentPowerResult, DefaultBrightnessState, + DefaultColorLightState, DefaultLightState, DefaultPlugState, DefaultPowerType, + DefaultRgbLightStripState, DefaultRgbicLightStripState, DefaultStateType, + DeviceInfoColorLightResult, DeviceInfoGenericResult, DeviceInfoHubResult, + DeviceInfoLightResult, DeviceInfoPlugEnergyMonitoringResult, DeviceInfoPlugResult, + DeviceInfoPowerStripResult, DeviceInfoRgbLightStripResult, DeviceInfoRgbicLightStripResult, + DeviceUsageEnergyMonitoringResult, DeviceUsageResult, EnergyDataIntervalResult, + EnergyDataResult, EnergyUsageResult, KE100Result, OvercurrentStatus, OverheatStatus, PlugState, + PowerDataIntervalResult, PowerDataResult, PowerProtectionStatus, + PowerStripPlugEnergyMonitoringResult, PowerStripPlugResult, RgbLightStripState, + RgbicLightStripState, S200BLog, S200BResult, S200BRotationParams, Status, T31XResult, T100Log, + T100Result, T110Log, T110Result, T300Log, T300Result, TemperatureHumidityRecord, + TemperatureHumidityRecords, TemperatureUnit, TemperatureUnitKE100, UsageByPeriodResult, + WaterLeakStatus, +}; + +use api::{ + PyApiClient, PyColorLightHandler, PyDeviceDiscovery, PyDeviceDiscoveryIter, PyDiscoveryResult, + PyGenericDeviceHandler, PyHubHandler, PyKE100Handler, PyLightHandler, PyMaybeDiscoveryResult, + PyPlugEnergyMonitoringHandler, PyPlugHandler, PyPowerStripEnergyMonitoringHandler, + PyPowerStripHandler, PyPowerStripPlugEnergyMonitoringHandler, PyPowerStripPlugHandler, + PyRgbLightStripHandler, PyRgbicLightStripHandler, PyT31XHandler, PyT100Handler, PyT110Handler, + PyT300Handler, +}; +use requests::{ + PyAlarmDuration, PyColorLightSetDeviceInfoParams, PyEnergyDataInterval, PyLightingEffect, + PyPowerDataInterval, +}; +use responses::{ + TriggerLogsS200BResult, TriggerLogsT100Result, TriggerLogsT110Result, TriggerLogsT300Result, +}; + +#[pymodule] +#[pyo3(name = "tapo")] +fn tapo_py(py: Python, module: &Bound<'_, PyModule>) -> PyResult<()> { + Logger::new(py, Caching::LoggersAndLevels)? + .filter(LevelFilter::Trace) + .install() + .expect("Failed to install the logger"); + + let requests = PyModule::new(py, "tapo.requests")?; + let responses = PyModule::new(py, "tapo.responses")?; + + register_handlers(module)?; + register_requests(&requests)?; + register_responses(&responses)?; + register_responses_hub(&responses)?; + register_responses_power_strip(&responses)?; + + module.add_submodule(&requests)?; + module.add_submodule(&responses)?; + + let sys = py.import("sys")?; + let modules = sys.getattr("modules")?; + modules.set_item("tapo.requests", requests)?; + modules.set_item("tapo.responses", responses)?; + + Ok(()) +} + +fn register_requests(module: &Bound<'_, PyModule>) -> Result<(), PyErr> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + // hub requests + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + Ok(()) +} + +fn register_handlers(module: &Bound<'_, PyModule>) -> Result<(), PyErr> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + Ok(()) +} + +fn register_responses(module: &Bound<'_, PyModule>) -> Result<(), PyErr> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + // device info: generic + module.add_class::()?; + + // device info: light + module.add_class::()?; + module.add_class::()?; + + // device info: color light + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + // device info: rgb light strip + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + // device info: rgbic light strip + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + // device info: plugs + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + Ok(()) +} + +fn register_responses_hub(module: &Bound<'_, PyModule>) -> Result<(), PyErr> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + // child devices + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + Ok(()) +} + +fn register_responses_power_strip(module: &Bound<'_, PyModule>) -> Result<(), PyErr> { + module.add_class::()?; + + // child devices + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/requests.rs b/agents/tapo/tapo-fork/tapo-py/src/requests.rs new file mode 100644 index 0000000..1dd6de2 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/requests.rs @@ -0,0 +1,9 @@ +mod energy_data_interval; +mod play_alarm; +mod power_data_interval; +mod set_device_info; + +pub use energy_data_interval::*; +pub use play_alarm::*; +pub use power_data_interval::*; +pub use set_device_info::*; diff --git a/agents/tapo/tapo-fork/tapo-py/src/requests/energy_data_interval.rs b/agents/tapo/tapo-fork/tapo-py/src/requests/energy_data_interval.rs new file mode 100644 index 0000000..cee44d5 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/requests/energy_data_interval.rs @@ -0,0 +1,9 @@ +use pyo3::prelude::*; + +#[derive(Clone, PartialEq)] +#[pyclass(name = "EnergyDataInterval", eq, eq_int)] +pub enum PyEnergyDataInterval { + Hourly, + Daily, + Monthly, +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/requests/play_alarm.rs b/agents/tapo/tapo-fork/tapo-py/src/requests/play_alarm.rs new file mode 100644 index 0000000..a5c6a4c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/requests/play_alarm.rs @@ -0,0 +1,9 @@ +use pyo3::prelude::*; + +#[derive(Debug, Clone, PartialEq)] +#[pyclass(name = "AlarmDuration", eq)] +pub enum PyAlarmDuration { + Continuous, + Once, + Seconds, +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/requests/power_data_interval.rs b/agents/tapo/tapo-fork/tapo-py/src/requests/power_data_interval.rs new file mode 100644 index 0000000..3078b97 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/requests/power_data_interval.rs @@ -0,0 +1,8 @@ +use pyo3::prelude::*; + +#[derive(Clone, PartialEq)] +#[pyclass(name = "PowerDataInterval", eq, eq_int)] +pub enum PyPowerDataInterval { + Every5Minutes, + Hourly, +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/requests/set_device_info.rs b/agents/tapo/tapo-fork/tapo-py/src/requests/set_device_info.rs new file mode 100644 index 0000000..a9bcbd2 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/requests/set_device_info.rs @@ -0,0 +1,5 @@ +mod color_light; +mod lighting_effect; + +pub use color_light::*; +pub use lighting_effect::*; diff --git a/agents/tapo/tapo-fork/tapo-py/src/requests/set_device_info/color_light.rs b/agents/tapo/tapo-fork/tapo-py/src/requests/set_device_info/color_light.rs new file mode 100644 index 0000000..42cc262 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/requests/set_device_info/color_light.rs @@ -0,0 +1,108 @@ +use std::ops::Deref; + +use pyo3::prelude::*; +use tapo::requests::{Color, ColorLightSetDeviceInfoParams}; + +use crate::api::{ + PyColorLightHandler, PyHandlerExt, PyRgbLightStripHandler, PyRgbicLightStripHandler, +}; +use crate::errors::ErrorWrapper; +use crate::runtime::tokio; + +#[derive(Clone)] +#[pyclass(name = "LightSetDeviceInfoParams")] +pub struct PyColorLightSetDeviceInfoParams { + params: ColorLightSetDeviceInfoParams, +} + +impl PyColorLightSetDeviceInfoParams { + pub(crate) fn new() -> Self { + Self { + params: ColorLightSetDeviceInfoParams::new(), + } + } + + async fn _send_to_inner_handler(&self, handler: impl PyHandlerExt) -> PyResult<()> { + let params = self.params.clone(); + let handler = handler.get_inner_handler(); + + tokio() + .spawn(async move { + let handler_lock = handler.read().await; + + params + .send(handler_lock.deref()) + .await + .map_err(ErrorWrapper)?; + + Ok::<_, ErrorWrapper>(()) + }) + .await + .map_err(anyhow::Error::from) + .map_err(ErrorWrapper::from)??; + + Ok(()) + } +} + +#[pymethods] +impl PyColorLightSetDeviceInfoParams { + pub fn on(&self) -> Self { + Self { + params: self.params.clone().on(), + } + } + + pub fn off(&self) -> Self { + Self { + params: self.params.clone().off(), + } + } + + pub fn brightness(&self, brightness: u8) -> Self { + Self { + params: self.params.clone().brightness(brightness), + } + } + + pub fn color(&self, color: Color) -> Self { + Self { + params: self.params.clone().color(color), + } + } + + pub fn hue_saturation(&self, hue: u16, saturation: u8) -> Self { + Self { + params: self.params.clone().hue_saturation(hue, saturation), + } + } + + pub fn color_temperature(&self, color_temperature: u16) -> Self { + Self { + params: self.params.clone().color_temperature(color_temperature), + } + } + + async fn send(&self, handler: Py) -> PyResult<()> { + if let Some(handler) = Python::attach(|py| handler.extract::(py).ok()) + { + return self._send_to_inner_handler(handler).await; + } + + if let Some(handler) = + Python::attach(|py| handler.extract::(py).ok()) + { + return self._send_to_inner_handler(handler).await; + } + + if let Some(handler) = + Python::attach(|py| handler.extract::(py).ok()) + { + return self._send_to_inner_handler(handler).await; + } + + Err(PyErr::new::( + "Invalid handler type. Must be one of `PyColorLightHandler`, `PyRgbLightStripHandler` or `PyRgbicLightStripHandler`", + )) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/requests/set_device_info/lighting_effect.rs b/agents/tapo/tapo-fork/tapo-py/src/requests/set_device_info/lighting_effect.rs new file mode 100644 index 0000000..d34d0bb --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/requests/set_device_info/lighting_effect.rs @@ -0,0 +1,201 @@ +use pyo3::prelude::*; +use tapo::requests::{LightingEffect, LightingEffectType}; + +#[derive(Clone)] +#[pyclass(name = "LightingEffect")] +pub struct PyLightingEffect { + inner: LightingEffect, +} + +#[pymethods] +impl PyLightingEffect { + #[new] + fn new( + name: String, + r#type: LightingEffectType, + is_custom: bool, + enabled: bool, + brightness: u8, + display_colors: Vec<[u16; 3]>, + ) -> Self { + Self { + inner: LightingEffect::new( + name, + r#type, + is_custom, + enabled, + brightness, + display_colors, + ), + } + } + + pub fn with_brightness(mut slf: PyRefMut<'_, Self>, brightness: u8) -> PyRefMut<'_, Self> { + (*slf).inner.brightness = brightness; + slf + } + + pub fn with_is_custom(mut slf: PyRefMut<'_, Self>, is_custom: bool) -> PyRefMut<'_, Self> { + (*slf).inner.is_custom = is_custom; + slf + } + + pub fn with_display_colors( + mut slf: PyRefMut<'_, Self>, + display_colors: Vec<[u16; 3]>, + ) -> PyRefMut<'_, Self> { + (*slf).inner.display_colors = display_colors; + slf + } + + pub fn with_enabled(mut slf: PyRefMut<'_, Self>, enabled: bool) -> PyRefMut<'_, Self> { + (*slf).inner.enabled = enabled; + slf + } + + pub fn with_id(mut slf: PyRefMut<'_, Self>, id: String) -> PyRefMut<'_, Self> { + (*slf).inner.id = id; + slf + } + + pub fn with_name(mut slf: PyRefMut<'_, Self>, name: String) -> PyRefMut<'_, Self> { + (*slf).inner.name = name; + slf + } + + pub fn with_type( + mut slf: PyRefMut<'_, Self>, + r#type: LightingEffectType, + ) -> PyRefMut<'_, Self> { + (*slf).inner.r#type = r#type; + slf + } + + pub fn with_backgrounds( + mut slf: PyRefMut<'_, Self>, + backgrounds: Vec<[u16; 3]>, + ) -> PyRefMut<'_, Self> { + (*slf).inner.backgrounds = Some(backgrounds); + slf + } + + pub fn with_brightness_range( + mut slf: PyRefMut<'_, Self>, + brightness_range: [u8; 2], + ) -> PyRefMut<'_, Self> { + (*slf).inner.brightness_range = Some(brightness_range.to_vec()); + slf + } + + pub fn with_direction(mut slf: PyRefMut<'_, Self>, direction: u8) -> PyRefMut<'_, Self> { + (*slf).inner.direction = Some(direction); + slf + } + + pub fn with_duration(mut slf: PyRefMut<'_, Self>, duration: u64) -> PyRefMut<'_, Self> { + (*slf).inner.duration = Some(duration); + slf + } + + pub fn with_expansion_strategy( + mut slf: PyRefMut<'_, Self>, + expansion_strategy: u8, + ) -> PyRefMut<'_, Self> { + (*slf).inner.expansion_strategy = Some(expansion_strategy); + slf + } + + pub fn with_fade_off(mut slf: PyRefMut<'_, Self>, fade_off: u16) -> PyRefMut<'_, Self> { + (*slf).inner.fade_off = Some(fade_off); + slf + } + + pub fn with_hue_range(mut slf: PyRefMut<'_, Self>, hue_range: [u16; 2]) -> PyRefMut<'_, Self> { + (*slf).inner.hue_range = Some(hue_range); + slf + } + + pub fn with_init_states( + mut slf: PyRefMut<'_, Self>, + init_states: Vec<[u16; 3]>, + ) -> PyRefMut<'_, Self> { + (*slf).inner.init_states = Some(init_states); + slf + } + + pub fn with_random_seed(mut slf: PyRefMut<'_, Self>, random_seed: u64) -> PyRefMut<'_, Self> { + (*slf).inner.random_seed = Some(random_seed); + slf + } + + pub fn with_repeat_times(mut slf: PyRefMut<'_, Self>, repeat_times: u8) -> PyRefMut<'_, Self> { + (*slf).inner.repeat_times = Some(repeat_times); + slf + } + + pub fn with_run_time(mut slf: PyRefMut<'_, Self>, run_time: u64) -> PyRefMut<'_, Self> { + (*slf).inner.run_time = Some(run_time); + slf + } + + pub fn with_saturation_range( + mut slf: PyRefMut<'_, Self>, + saturation_range: [u8; 2], + ) -> PyRefMut<'_, Self> { + (*slf).inner.saturation_range = Some(saturation_range); + slf + } + + pub fn with_segment_length( + mut slf: PyRefMut<'_, Self>, + segment_length: u8, + ) -> PyRefMut<'_, Self> { + (*slf).inner.segment_length = Some(segment_length); + slf + } + + pub fn with_segments(mut slf: PyRefMut<'_, Self>, segments: Vec) -> PyRefMut<'_, Self> { + (*slf).inner.segments = Some(segments); + slf + } + + pub fn with_sequence( + mut slf: PyRefMut<'_, Self>, + sequence: Vec<[u16; 3]>, + ) -> PyRefMut<'_, Self> { + (*slf).inner.sequence = Some(sequence); + slf + } + + pub fn with_spread(mut slf: PyRefMut<'_, Self>, spread: u8) -> PyRefMut<'_, Self> { + (*slf).inner.spread = Some(spread); + slf + } + + pub fn with_transition(mut slf: PyRefMut<'_, Self>, transition: u16) -> PyRefMut<'_, Self> { + (*slf).inner.transition = Some(transition); + slf + } + + pub fn with_transition_range( + mut slf: PyRefMut<'_, Self>, + transition_range: [u16; 2], + ) -> PyRefMut<'_, Self> { + (*slf).inner.transition_range = Some(transition_range); + slf + } + + pub fn with_transition_sequence( + mut slf: PyRefMut<'_, Self>, + transition_sequence: Vec, + ) -> PyRefMut<'_, Self> { + (*slf).inner.transition_sequence = Some(transition_sequence); + slf + } +} + +impl From for LightingEffect { + fn from(effect: PyLightingEffect) -> Self { + effect.inner + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/responses.rs b/agents/tapo/tapo-fork/tapo-py/src/responses.rs new file mode 100644 index 0000000..d296c14 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/responses.rs @@ -0,0 +1,3 @@ +mod child_device_list_hub_result; + +pub use child_device_list_hub_result::*; diff --git a/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result.rs b/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result.rs new file mode 100644 index 0000000..a8b7108 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result.rs @@ -0,0 +1,9 @@ +mod s200b_result; +mod t100_result; +mod t110_result; +mod t300_result; + +pub use s200b_result::*; +pub use t100_result::*; +pub use t110_result::*; +pub use t300_result::*; diff --git a/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/s200b_result.rs b/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/s200b_result.rs new file mode 100644 index 0000000..3ddae4c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/s200b_result.rs @@ -0,0 +1,32 @@ +use pyo3::prelude::*; +use serde::{Deserialize, Serialize}; +use tapo::responses::{S200BLog, TriggerLogsResult}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[pyclass(get_all)] +#[allow(missing_docs)] +pub struct TriggerLogsS200BResult { + start_id: u64, + sum: u64, + logs: Vec, +} + +impl From> for TriggerLogsS200BResult { + fn from(result: TriggerLogsResult) -> Self { + Self { + start_id: result.start_id, + sum: result.sum, + logs: result.logs, + } + } +} + +#[pyo3::pymethods] +impl TriggerLogsS200BResult { + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + tapo::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/t100_result.rs b/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/t100_result.rs new file mode 100644 index 0000000..1feac08 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/t100_result.rs @@ -0,0 +1,32 @@ +use pyo3::prelude::*; +use serde::{Deserialize, Serialize}; +use tapo::responses::{T100Log, TriggerLogsResult}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[pyclass(get_all)] +#[allow(missing_docs)] +pub struct TriggerLogsT100Result { + start_id: u64, + sum: u64, + logs: Vec, +} + +impl From> for TriggerLogsT100Result { + fn from(result: TriggerLogsResult) -> Self { + Self { + start_id: result.start_id, + sum: result.sum, + logs: result.logs, + } + } +} + +#[pyo3::pymethods] +impl TriggerLogsT100Result { + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + tapo::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/t110_result.rs b/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/t110_result.rs new file mode 100644 index 0000000..02e181c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/t110_result.rs @@ -0,0 +1,32 @@ +use pyo3::prelude::*; +use serde::{Deserialize, Serialize}; +use tapo::responses::{T110Log, TriggerLogsResult}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[pyclass(get_all)] +#[allow(missing_docs)] +pub struct TriggerLogsT110Result { + start_id: u64, + sum: u64, + logs: Vec, +} + +impl From> for TriggerLogsT110Result { + fn from(result: TriggerLogsResult) -> Self { + Self { + start_id: result.start_id, + sum: result.sum, + logs: result.logs, + } + } +} + +#[pyo3::pymethods] +impl TriggerLogsT110Result { + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + tapo::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/t300_result.rs b/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/t300_result.rs new file mode 100644 index 0000000..8064e55 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/responses/child_device_list_hub_result/t300_result.rs @@ -0,0 +1,32 @@ +use pyo3::prelude::*; +use serde::{Deserialize, Serialize}; +use tapo::responses::{T300Log, TriggerLogsResult}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[pyclass(get_all)] +#[allow(missing_docs)] +pub struct TriggerLogsT300Result { + start_id: u64, + sum: u64, + logs: Vec, +} + +impl From> for TriggerLogsT300Result { + fn from(result: TriggerLogsResult) -> Self { + Self { + start_id: result.start_id, + sum: result.sum, + logs: result.logs, + } + } +} + +#[pyo3::pymethods] +impl TriggerLogsT300Result { + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + tapo::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo-py/src/runtime.rs b/agents/tapo/tapo-fork/tapo-py/src/runtime.rs new file mode 100644 index 0000000..f7eed51 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/src/runtime.rs @@ -0,0 +1,60 @@ +pub fn tokio() -> &'static tokio::runtime::Runtime { + use std::sync::OnceLock; + use tokio::runtime::Runtime; + static RT: std::sync::OnceLock = OnceLock::new(); + RT.get_or_init(|| Runtime::new().expect("Failed to create tokio runtime")) +} + +#[macro_export] +macro_rules! call_handler_constructor { + ($self:ident, $constructor:path, $($params:expr),*) => {{ + let client = $self.client.clone(); + let handler = $crate::runtime::tokio() + .spawn(async move { + $constructor(client, $($params),*) + .await + .map_err($crate::errors::ErrorWrapper) + }) + .await + .map_err(anyhow::Error::from) + .map_err($crate::errors::ErrorWrapper::from)??; + + handler + }}; +} + +#[macro_export] +macro_rules! call_handler_method { + ($handler:expr, $method:path) => (call_handler_method!($handler, $method,)); + ($handler:expr, $method:path, discard_result) => (call_handler_method!($handler, $method, discard_result,)); + ($handler:expr, $method:path, $($param:expr),*) => {{ + let result = $crate::runtime::tokio() + .spawn(async move { + let result = $method($handler, $($param),*) + .await + .map_err($crate::errors::ErrorWrapper)?; + + Ok::<_, $crate::errors::ErrorWrapper>(result) + }) + .await + .map_err(anyhow::Error::from) + .map_err($crate::errors::ErrorWrapper::from)??; + + Ok::<_, PyErr>(result) + }}; + ($handler:expr, $method:path, discard_result, $($param:expr),*) => {{ + let result = $crate::runtime::tokio() + .spawn(async move { + $method($handler, $($param),*) + .await + .map_err($crate::errors::ErrorWrapper)?; + + Ok::<_, $crate::errors::ErrorWrapper>(()) + }) + .await + .map_err(anyhow::Error::from) + .map_err($crate::errors::ErrorWrapper::from)??; + + Ok(result) + }}; +} diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/__init__.py b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/__init__.py new file mode 100644 index 0000000..1f10229 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/__init__.py @@ -0,0 +1,5 @@ +from .tapo import * + +__doc__ = tapo.__doc__ +if hasattr(tapo, "__all__"): + __all__ = tapo.__all__ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/__init__.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/__init__.pyi new file mode 100644 index 0000000..a683a65 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/__init__.pyi @@ -0,0 +1,23 @@ +from .api_client import * +from .color_light_handler import * +from .device_discovery import * +from .discovery_result import * +from .generic_device_handler import * +from .hub_handler import * +from .ke100_handler import * +from .light_handler import * +from .plug_energy_monitoring_handler import * +from .plug_handler import * +from .power_strip_energy_monitoring_handler import * +from .power_strip_handler import * +from .power_strip_plug_energy_monitoring_handler import * +from .power_strip_plug_handler import * +from .requests import * +from .responses import * +from .rgb_light_strip_handler import * +from .rgbic_light_strip_handler import * +from .s200b_handler import * +from .t100_handler import * +from .t110_handler import * +from .t300_handler import * +from .t31x_handler import * diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/api_client.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/api_client.pyi new file mode 100644 index 0000000..de0ca7f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/api_client.pyi @@ -0,0 +1,487 @@ +"""Tapo API Client. + +Tested with light bulbs (L510, L520, L530, L535, L610, L630), light strips (L900, L920, L930), plugs (P100, P105, P110, P110M, P115), +power strips (P300, P304M, P306, P316M), hubs (H100), switches (S200B) and sensors (KE100, T100, T110, T300, T310, T315). + +Example: + ```python + import asyncio + from tapo import ApiClient + + + async def main(): + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l530("192.168.1.100") + + await device.on() + + if __name__ == "__main__": + asyncio.run(main()) + ``` + +See [more examples](https://github.com/mihai-dinculescu/tapo/tree/main/tapo-py/examples). +""" + +from .color_light_handler import ColorLightHandler +from .device_discovery import DeviceDiscovery +from .generic_device_handler import GenericDeviceHandler +from .hub_handler import HubHandler +from .light_handler import LightHandler +from .plug_energy_monitoring_handler import PlugEnergyMonitoringHandler +from .plug_handler import PlugHandler +from .power_strip_energy_monitoring_handler import PowerStripEnergyMonitoringHandler +from .power_strip_handler import PowerStripHandler +from .rgb_light_strip_handler import RgbLightStripHandler +from .rgbic_light_strip_handler import RgbicLightStripHandler + +class ApiClient: + """Tapo API Client. + + Tested with light bulbs (L510, L520, L530, L535, L610, L630), light strips (L900, L920, L930), plugs (P100, P105, P110, P110M, P115), + power strips (P300, P304M, P306, P316M), hubs (H100), switches (S200B) and sensors (KE100, T100, T110, T300, T310, T315). + + Example: + ```python + import asyncio + from tapo import ApiClient + + + async def main(): + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l530("192.168.1.100") + + await device.on() + + if __name__ == "__main__": + asyncio.run(main()) + ``` + + See [more examples](https://github.com/mihai-dinculescu/tapo/tree/main/tapo-py/examples). + """ + + def __init__(self, tapo_username: str, tapo_password: str, timeout_s: int = 30) -> None: + """Returns a new instance of `ApiClient`. + + Args: + tapo_username (str): The Tapo username. + tapo_password (str): The Tapo password. + timeout_s (int): The connection timeout in seconds. The default value is 30 seconds. + + Returns: + ApiClient: Tapo API Client. + + Example: + ```python + import asyncio + from tapo import ApiClient + + + async def main(): + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l530("192.168.1.100") + + await device.on() + + if __name__ == "__main__": + asyncio.run(main()) + ``` + + See [more examples](https://github.com/mihai-dinculescu/tapo/tree/main/tapo-py/examples). + """ + + async def discover_devices(self, target: str, timeout_s: int = 10) -> DeviceDiscovery: + """Discovers one or more devices located at a specified unicast or broadcast IP address. + + Args: + target (str): The IP address at which the discovery will take place. + This address can be either a unicast (e.g. `192.168.1.10`) or a + broadcast address (e.g. `192.168.1.255`, `255.255.255.255`, etc.). + timeout_s (int): The maximum time to wait for a response from the device(s) in seconds. + Must be between `1` and `60`. + + Returns: + AsyncIterator[MaybeDiscoveryResult]: An asynchronous iterator that yields `MaybeDiscoveryResult` objects. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + + async for device in client.discover_devices("192.168.1.255"): + try: + device = discovery_result.get() + match device: + case DiscoveryResult.PlugEnergyMonitoring(device_info): + print( + f"Found '{device_info.nickname}' of model '{device_info.model}' at IP address '{device_info.ip}'." + ) + # ... + except Exception as e: + print(f"Error discovering device: {e}") + ``` + """ + + async def generic_device(self, ip_address: str) -> GenericDeviceHandler: + """Specializes the given `ApiClient` into an authenticated `GenericDeviceHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + GenericDeviceHandler: Handler for generic devices. It provides the + functionality common to all Tapo [devices](https://www.tapo.com/en/). + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.generic_device("192.168.1.100") + + await device.on() + ``` + """ + + async def l510(self, ip_address: str) -> LightHandler: + """Specializes the given `ApiClient` into an authenticated `LightHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + LightHandler: Handler for the [L510](https://www.tapo.com/en/search/?q=L510), + [L520](https://www.tapo.com/en/search/?q=L520) and [L610](https://www.tapo.com/en/search/?q=L610) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l510("192.168.1.100") + + await device.on() + ``` + """ + + async def l520(self, ip_address: str) -> LightHandler: + """Specializes the given `ApiClient` into an authenticated `LightHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + LightHandler: Handler for the [L510](https://www.tapo.com/en/search/?q=L510), + [L520](https://www.tapo.com/en/search/?q=L520) and [L610](https://www.tapo.com/en/search/?q=L610) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l520("192.168.1.100") + + await device.on() + ``` + """ + + async def l530(self, ip_address: str) -> ColorLightHandler: + """Specializes the given `ApiClient` into an authenticated `ColorLightHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + ColorLightHandler: Handler for the [L530](https://www.tapo.com/en/search/?q=L530), + [L535](https://www.tapo.com/en/search/?q=L535) and [L630](https://www.tapo.com/en/search/?q=L630) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l530("192.168.1.100") + + await device.on() + ``` + """ + + async def l535(self, ip_address: str) -> ColorLightHandler: + """Specializes the given `ApiClient` into an authenticated `ColorLightHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + ColorLightHandler: Handler for the [L530](https://www.tapo.com/en/search/?q=L530), + [L535](https://www.tapo.com/en/search/?q=L535) and [L630](https://www.tapo.com/en/search/?q=L630) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l535("192.168.1.100") + + await device.on() + ``` + """ + + async def l610(self, ip_address: str) -> LightHandler: + """Specializes the given `ApiClient` into an authenticated `LightHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + LightHandler: Handler for the [L510](https://www.tapo.com/en/search/?q=L510), + [L520](https://www.tapo.com/en/search/?q=L520) and [L610](https://www.tapo.com/en/search/?q=L610) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l610("192.168.1.100") + + await device.on() + ``` + """ + + async def l630(self, ip_address: str) -> ColorLightHandler: + """Specializes the given `ApiClient` into an authenticated `ColorLightHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + ColorLightHandler: Handler for the [L530](https://www.tapo.com/en/search/?q=L530), + [L630](https://www.tapo.com/en/search/?q=L630) and [L900](https://www.tapo.com/en/search/?q=L900) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l630("192.168.1.100") + + await device.on() + ``` + """ + + async def l900(self, ip_address: str) -> RgbLightStripHandler: + """Specializes the given `ApiClient` into an authenticated `RgbLightStripHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + RgbLightStripHandler: Handler for the [L900](https://www.tapo.com/en/search/?q=L900) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l900("192.168.1.100") + + await device.on() + ``` + """ + + async def l920(self, ip_address: str) -> RgbicLightStripHandler: + """Specializes the given `ApiClient` into an authenticated `RgbicLightStripHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + RgbicLightStripHandler: Handler for the [L920](https://www.tapo.com/en/search/?q=L920) and + [L930](https://www.tapo.com/en/search/?q=L930) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l920("192.168.1.100") + + await device.on() + ``` + """ + + async def l930(self, ip_address: str) -> RgbicLightStripHandler: + """Specializes the given `ApiClient` into an authenticated `RgbicLightStripHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + RgbicLightStripHandler: Handler for the [L920](https://www.tapo.com/en/search/?q=L920) and + [L930](https://www.tapo.com/en/search/?q=L930) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.l930("192.168.1.100") + + await device.on() + ``` + """ + + async def p100(self, ip_address: str) -> PlugHandler: + """Specializes the given `ApiClient` into an authenticated `PlugHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + PlugHandler: Handler for the [P100](https://www.tapo.com/en/search/?q=P100) and + [P105](https://www.tapo.com/en/search/?q=P105) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.p100("192.168.1.100") + + await device.on() + ``` + """ + + async def p105(self, ip_address: str) -> PlugHandler: + """Specializes the given `ApiClient` into an authenticated `PlugHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + PlugHandler: Handler for the [P100](https://www.tapo.com/en/search/?q=P100) and + [P105](https://www.tapo.com/en/search/?q=P105) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.p105("192.168.1.100") + + await device.on() + ``` + """ + + async def p110(self, ip_address: str) -> PlugEnergyMonitoringHandler: + """Specializes the given `ApiClient` into an authenticated `PlugEnergyMonitoringHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + PlugEnergyMonitoringHandler: Handler for the [P110](https://www.tapo.com/en/search/?q=P110), + [P110M](https://www.tapo.com/en/search/?q=P110M) and + [P115](https://www.tapo.com/en/search/?q=P115) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.p110("192.168.1.100") + + await device.on() + ``` + """ + + async def p115(self, ip_address: str) -> PlugEnergyMonitoringHandler: + """Specializes the given `ApiClient` into an authenticated `PlugEnergyMonitoringHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + PlugEnergyMonitoringHandler: Handler for the [P110](https://www.tapo.com/en/search/?q=P110), + [P110M](https://www.tapo.com/en/search/?q=P110M) and + [P115](https://www.tapo.com/en/search/?q=P115) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + device = await client.p115("192.168.1.100") + + await device.on() + ``` + """ + + async def p300(self, ip_address: str) -> PowerStripHandler: + """Specializes the given `ApiClient` into an authenticated `PowerStripHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + PowerStripHandler: Handler for the [P300](https://www.tp-link.com/en/search/?q=P300) and + [P306](https://www.tp-link.com/us/search/?q=P306) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + power_strip = await client.p300("192.168.1.100") + + child_device_list = await power_strip.get_child_device_list() + print(f"Child device list: {child_device_list.to_dict()}") + ``` + """ + + async def p304(self, ip_address: str) -> PowerStripEnergyMonitoringHandler: + """Specializes the given `ApiClient` into an authenticated `PowerStripEnergyMonitoringHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + PowerStripEnergyMonitoringHandler: Handler for the [P304M](https://www.tp-link.com/uk/search/?q=P304M) and + [P316M](https://www.tp-link.com/us/search/?q=P316M) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + power_strip = await client.p304("192.168.1.100") + + child_device_list = await power_strip.get_child_device_list() + print(f"Child device list: {child_device_list.to_dict()}") + ``` + """ + + async def p306(self, ip_address: str) -> PowerStripHandler: + """Specializes the given `ApiClient` into an authenticated `PowerStripHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + PowerStripHandler: Handler for the [P300](https://www.tp-link.com/en/search/?q=P300) and + [P306](https://www.tp-link.com/us/search/?q=P306) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + power_strip = await client.p306("192.168.1.100") + + child_device_list = await power_strip.get_child_device_list() + print(f"Child device list: {child_device_list.to_dict()}") + ``` + """ + + async def p316(self, ip_address: str) -> PowerStripHandler: + """Specializes the given `ApiClient` into an authenticated `PowerStripHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + PowerStripEnergyMonitoringHandler: Handler for the [P304M](https://www.tp-link.com/uk/search/?q=P304M) and + [P316M](https://www.tp-link.com/us/search/?q=P316M) devices. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + power_strip = await client.p316("192.168.1.100") + + child_device_list = await power_strip.get_child_device_list() + print(f"Child device list: {child_device_list.to_dict()}") + ``` + """ + + async def h100(self, ip_address: str) -> HubHandler: + """Specializes the given `ApiClient` into an authenticated `HubHandler`. + + Args: + ip_address (str): The IP address of the device + + Returns: + HubHandler: Handler for the [H100](https://www.tapo.com/en/search/?q=H100) hubs. + + Example: + ```python + client = ApiClient("tapo-username@example.com", "tapo-password") + hub = await client.h100("192.168.1.100") + + child_device_list = await hub.get_child_device_list() + print(f"Child device list: {child_device_list.to_dict()}") + ``` + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/color_light_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/color_light_handler.pyi new file mode 100644 index 0000000..73accd8 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/color_light_handler.pyi @@ -0,0 +1,88 @@ +from tapo.device_management_ext import DeviceManagementExt +from tapo.requests import Color, ColorLightSetDeviceInfoParams +from tapo.responses import DeviceInfoColorLightResult, DeviceUsageResult + +class ColorLightHandler(DeviceManagementExt): + """Handler for the [L530](https://www.tapo.com/en/search/?q=L530), + [L535](https://www.tapo.com/en/search/?q=L535) and + [L630](https://www.tapo.com/en/search/?q=L630) devices. + """ + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def refresh_session(self) -> None: + """Refreshes the authentication session.""" + + async def on(self) -> None: + """Turns *on* the device.""" + + async def off(self) -> None: + """Turns *off* the device.""" + + async def get_device_info(self) -> DeviceInfoColorLightResult: + """Returns *device info* as `DeviceInfoColorLightResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `ColorLightHandler.get_device_info_json`. + + Returns: + DeviceInfoColorLightResult: Device info of Tapo L530, L535 and L630. + Superset of `GenericDeviceInfoResult`. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_device_usage(self) -> DeviceUsageResult: + """Returns *device usage* as `DeviceUsageResult`. + + Returns: + DeviceUsageResult: Contains the time usage. + """ + + def set(self) -> ColorLightSetDeviceInfoParams: + """Returns a `ColorLightSetDeviceInfoParams` builder that allows + multiple properties to be set in a single request. + `ColorLightSetDeviceInfoParams.send` must be called at the end to apply the changes. + + Returns: + ColorLightSetDeviceInfoParams: Builder that is used by the + `ColorLightHandler.set` API to set multiple properties in a single request. + """ + + async def set_brightness(self, brightness: int) -> None: + """Sets the *brightness* and turns *on* the device. + + Args: + brightness (int): between 1 and 100 + """ + + async def set_color(self, color: Color) -> None: + """Sets the *color* and turns *on* the device. + + Args: + color (Color): one of `tapo.Color` as defined in the Google Home app. + """ + + async def set_hue_saturation(self, hue: int, saturation: int) -> None: + """Sets the *hue*, *saturation* and turns *on* the device. + + Args: + hue (int): between 0 and 360 + saturation (int): between 1 and 100 + """ + + async def set_color_temperature(self, color_temperature: int) -> None: + """Sets the *color temperature* and turns *on* the device. + + Args: + color_temperature (int): between 2500 and 6500 + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/device_discovery.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/device_discovery.pyi new file mode 100644 index 0000000..7e472a1 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/device_discovery.pyi @@ -0,0 +1,14 @@ +from __future__ import annotations +from typing import AsyncIterable, Iterable, Iterator, AsyncIterator + +from .discovery_result import MaybeDiscoveryResult + +class DeviceDiscoveryIter(Iterator[MaybeDiscoveryResult], AsyncIterator[MaybeDiscoveryResult]): + def __iter__(self) -> DeviceDiscoveryIter: ... + def __aiter__(self) -> DeviceDiscoveryIter: ... + def __next__(self) -> MaybeDiscoveryResult: ... + async def __anext__(self) -> MaybeDiscoveryResult: ... + +class DeviceDiscovery(Iterable[MaybeDiscoveryResult], AsyncIterable[MaybeDiscoveryResult]): + def __iter__(self) -> DeviceDiscoveryIter: ... + def __aiter__(self) -> DeviceDiscoveryIter: ... diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/device_management_ext.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/device_management_ext.pyi new file mode 100644 index 0000000..af623ba --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/device_management_ext.pyi @@ -0,0 +1,27 @@ +class DeviceManagementExt: + """Extension class for device management capabilities like `device_reboot` and `device_reset`.""" + + async def device_reboot(self, delay_s: int) -> None: + """*Reboots* the device. + + Notes: + * Using a very small delay (e.g. 0 seconds) may cause a `ConnectionReset` or `TimedOut` error as the device reboots immediately. + * Using a larger delay (e.g. 2-3 seconds) allows the device to respond before rebooting, reducing the chance of errors. + * With larger delays, the method completes successfully before the device reboots. + However, subsequent commands may fail if sent during the reboot process or before the device reconnects to the network. + + Args: + delay_s (int): The delay in seconds before the device is rebooted. + """ + + async def device_reset(self) -> None: + """*Hardware resets* the device. + + Warning: + This action will reset the device to its factory settings. + The connection to the Wi-Fi network and the Tapo app will be lost, + and the device will need to be reconfigured. + + This feature is especially useful when the device is difficult to access + and requires reconfiguration. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/discovery_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/discovery_result.pyi new file mode 100644 index 0000000..b3bb0d0 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/discovery_result.pyi @@ -0,0 +1,238 @@ +from dataclasses import dataclass +from typing import Type, Union + +from tapo import ( + ColorLightHandler, + GenericDeviceHandler, + HubHandler, + LightHandler, + PlugEnergyMonitoringHandler, + PlugHandler, + PowerStripEnergyMonitoringHandler, + PowerStripHandler, + RgbicLightStripHandler, + RgbLightStripHandler, +) +from tapo.responses import ( + DeviceInfoColorLightResult, + DeviceInfoGenericResult, + DeviceInfoHubResult, + DeviceInfoLightResult, + DeviceInfoPlugEnergyMonitoringResult, + DeviceInfoPlugResult, + DeviceInfoPowerStripResult, + DeviceInfoRgbicLightStripResult, + DeviceInfoRgbLightStripResult, +) + +class MaybeDiscoveryResult: + """Potential result of the device discovery process. Using `get` will return the actual result or raise an exception.""" + + def get( + self, + ) -> Union[ + GenericDevice, + Light, + ColorLight, + RgbLightStrip, + RgbicLightStrip, + Plug, + PlugEnergyMonitoring, + PowerStrip, + PowerStripEnergyMonitoring, + Hub, + ]: + """Retrieves the actual discovery result or raises an exception.""" + +@dataclass +class GenericDevice: + """A Generic Tapo device. + + If you believe this device is already supported, or would like to explore adding support for a currently + unsupported model, please [open an issue on GitHub](https://github.com/mihai-dinculescu/tapo/issues) + to start the discussion. + """ + + device_info: DeviceInfoGenericResult + """Device info of a Generic Tapo device. + + If you believe this device is already supported, or would like to explore adding support for a currently + unsupported model, please [open an issue on GitHub](https://github.com/mihai-dinculescu/tapo/issues) + to start the discussion. + """ + + handler: GenericDeviceHandler + """Handler for generic devices. It provides the functionality common to all Tapo [devices](https://www.tapo.com/en/). + + If you believe this device is already supported, or would like to explore adding support for a currently + unsupported model, please [open an issue on GitHub](https://github.com/mihai-dinculescu/tapo/issues) + to start the discussion. + """ + + __match_args__ = ( + "device_info", + "handler", + ) + +@dataclass +class Light: + """Tapo L510, L520 and L610 devices.""" + + device_info: DeviceInfoLightResult + """Device info of Tapo L510, L520 and L610.""" + + handler: LightHandler + """Handler for the [L510](https://www.tapo.com/en/search/?q=L510), + [L520](https://www.tapo.com/en/search/?q=L520) and + [L610](https://www.tapo.com/en/search/?q=L610) devices. + """ + + __match_args__ = ( + "device_info", + "handler", + ) + +@dataclass +class ColorLight: + """Tapo L530, L535 and L630 devices.""" + + device_info: DeviceInfoColorLightResult + """Device info of Tapo L530, L535 and L630.""" + + handler: ColorLightHandler + """Handler for the [L530](https://www.tapo.com/en/search/?q=L530), + [L535](https://www.tapo.com/en/search/?q=L535) and + [L630](https://www.tapo.com/en/search/?q=L630) devices. + """ + + __match_args__ = ( + "device_info", + "handler", + ) + +@dataclass +class RgbLightStrip: + """Tapo L900 devices.""" + + device_info: DeviceInfoRgbLightStripResult + """Device info of Tapo L900.""" + + handler: RgbLightStripHandler + """Handler for the [L900](https://www.tapo.com/en/search/?q=L900) devices.""" + + __match_args__ = ( + "device_info", + "handler", + ) + +@dataclass +class RgbicLightStrip: + """Tapo L920 and L930 devices.""" + + device_info: DeviceInfoRgbicLightStripResult + """Device info of Tapo L920 and L930.""" + + handler: RgbicLightStripHandler + """Handler for the [L920](https://www.tapo.com/en/search/?q=L920) and + [L930](https://www.tapo.com/en/search/?q=L930) devices.""" + + __match_args__ = ( + "device_info", + "handler", + ) + +@dataclass +class Plug: + """Tapo P100 and P105 devices.""" + + device_info: DeviceInfoPlugResult + """Device info of Tapo P100 and P105.""" + + handler: PlugHandler + """Handler for the [P100](https://www.tapo.com/en/search/?q=P100) and + [P105](https://www.tapo.com/en/search/?q=P105) devices.""" + + __match_args__ = ( + "device_info", + "handler", + ) + +@dataclass +class PlugEnergyMonitoring: + """Tapo P110, P110M and P115 devices.""" + + device_info: DeviceInfoPlugEnergyMonitoringResult + """Device info of Tapo P110, P110M and P115.""" + + handler: PlugEnergyMonitoringHandler + """Handler for the [P110](https://www.tapo.com/en/search/?q=P110), + [P110M](https://www.tapo.com/en/search/?q=P110M) and + [P115](https://www.tapo.com/en/search/?q=P115) devices.""" + + __match_args__ = ( + "device_info", + "handler", + ) + +@dataclass +class PowerStrip: + """Tapo P300 and P306 devices.""" + + device_info: DeviceInfoPowerStripResult + """Device info of Tapo P300 and P306.""" + + handler: PowerStripHandler + """Handler for the [P300](https://www.tp-link.com/en/search/?q=P300) and + [P306](https://www.tp-link.com/us/search/?q=P306) devices. + """ + + __match_args__ = ( + "device_info", + "handler", + ) + +@dataclass +class PowerStripEnergyMonitoring: + """Tapo P304M and P316M devices.""" + + device_info: DeviceInfoPowerStripResult + """Device info of Tapo P304M and P316M.""" + + handler: PowerStripEnergyMonitoringHandler + """Handler for the [P304M](https://www.tp-link.com/uk/search/?q=P304M) and + [P316M](https://www.tp-link.com/us/search/?q=P316M) devices. + """ + + __match_args__ = ( + "device_info", + "handler", + ) + +@dataclass +class Hub: + """Tapo H100 devices.""" + + device_info: DeviceInfoHubResult + """Device info of Tapo H100.""" + + handler: HubHandler + """Handler for the [H100](https://www.tapo.com/en/search/?q=H100) devices.""" + + __match_args__ = ( + "device_info", + "handler", + ) + +class DiscoveryResult: + """Result of the device discovery process.""" + + GenericDevice: Type[GenericDevice] = GenericDevice + Light: Type[Light] = Light + ColorLight: Type[ColorLight] = ColorLight + RgbLightStrip: Type[RgbLightStrip] = RgbLightStrip + RgbicLightStrip: Type[RgbicLightStrip] = RgbicLightStrip + Plug: Type[Plug] = Plug + PlugEnergyMonitoring: Type[PlugEnergyMonitoring] = PlugEnergyMonitoring + PowerStrip: Type[PowerStrip] = PowerStrip + PowerStripEnergyMonitoring: Type[PowerStripEnergyMonitoring] = PowerStripEnergyMonitoring + Hub: Type[Hub] = Hub diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/generic_device_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/generic_device_handler.pyi new file mode 100644 index 0000000..f5a23a4 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/generic_device_handler.pyi @@ -0,0 +1,41 @@ +from tapo.responses import DeviceInfoGenericResult + +class GenericDeviceHandler: + """Handler for generic devices. It provides the functionality common to + all Tapo [devices](https://www.tapo.com/en/). + + If you'd like to propose support for a device that isn't currently supported, + please [open an issue on GitHub](https://github.com/mihai-dinculescu/tapo/issues) to start the conversation. + """ + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def refresh_session(self) -> None: + """Refreshes the authentication session.""" + + async def on(self) -> None: + """Turns *on* the device.""" + + async def off(self) -> None: + """Turns *off* the device.""" + + async def get_device_info(self) -> DeviceInfoGenericResult: + """Returns *device info* as `DeviceInfoGenericResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `GenericDeviceHandler.get_device_info_json`. + + Returns: + DeviceInfoGenericResult: Device info of a Generic Tapo device. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/hub_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/hub_handler.pyi new file mode 100644 index 0000000..ea8923f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/hub_handler.pyi @@ -0,0 +1,295 @@ +from typing import List, Optional, Union + +from tapo import KE100Handler, S200BHandler, T100Handler, T110Handler, T300Handler, T31XHandler +from tapo.device_management_ext import DeviceManagementExt +from tapo.requests import AlarmDuration, AlarmRingtone, AlarmVolume +from tapo.responses import ( + DeviceInfoHubResult, + KE100Result, + S200BResult, + T100Result, + T110Result, + T300Result, + T31XResult, +) + +class HubHandler(DeviceManagementExt): + """Handler for the [H100](https://www.tapo.com/en/search/?q=H100) devices.""" + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def refresh_session(self) -> None: + """Refreshes the authentication session.""" + + async def get_device_info(self) -> DeviceInfoHubResult: + """Returns *device info* as `DeviceInfoHubResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `HubHandler.get_device_info_json`. + + Returns: + DeviceInfoHubResult: Device info of Tapo H100. + Superset of `GenericDeviceInfoResult`. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_child_device_list( + self, + ) -> List[ + Union[KE100Result, S200BResult, T100Result, T110Result, T300Result, T31XResult, None] + ]: + """Returns *child device list* as `List[KE100Result | S200BResult | T100Result | T110Result | T300Result | T31XResult | None]`. + It is not guaranteed to contain all the properties returned from the Tapo API + or to support all the possible devices connected to the hub. + If the deserialization fails, or if a property that you care about it's not present, + try `HubHandler.get_child_device_list_json`. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_child_device_list_json(self, start_index: int) -> dict: + """Returns *child device list* as json. + It contains all the properties returned from the Tapo API. + + Args: + start_index (int): the index to start fetching the child device list. + It should be `0` for the first page, `10` for the second, and so on. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_child_device_component_list_json(self) -> dict: + """Returns *child device component list* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_supported_ringtone_list() -> List[str]: + """Returns a list of ringtones (alarm types) supported by the hub. + Used for debugging only. + + Returns: + List[str]: List of the ringtones supported by the hub. + """ + + async def play_alarm( + self, + ringtone: AlarmRingtone, + volume: AlarmVolume, + duration: AlarmDuration, + seconds: Optional[int] = None, + ) -> None: + """Start playing the hub alarm. + + Args: + ringtone (AlarmRingtone): The ringtone of a H100 alarm. + volume (AlarmVolume): The volume of the alarm. + duration (AlarmDuration): Controls how long the alarm plays for. + seconds (Optional[int]): Play the alarm a number of seconds. Required if `duration` is `AlarmDuration.Seconds`. + """ + + async def stop_alarm(self) -> None: + """Stop playing the hub alarm, if it's currently playing.""" + + async def ke100( + self, device_id: Optional[str] = None, nickname: Optional[str] = None + ) -> KE100Handler: + """Returns a `KE100Handler` for the device matching the provided `device_id` or `nickname`. + + Args: + device_id (Optional[str]): The Device ID of the device + nickname (Optional[str]): The Nickname of the device + + Returns: + KE100Handler: Handler for the [KE100](https://www.tp-link.com/en/search/?q=KE100) devices. + + Example: + ```python + # Connect to the hub + client = ApiClient("tapo-username@example.com", "tapo-password") + hub = await client.h100("192.168.1.100") + + # Get a handler for the child device + device = await hub.ke100(device_id="0000000000000000000000000000000000000000") + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + ``` + """ + + async def s200b( + self, device_id: Optional[str] = None, nickname: Optional[str] = None + ) -> S200BHandler: + """Returns a `S200BHandler` for the device matching the provided `device_id` or `nickname`. + + Args: + device_id (Optional[str]): The Device ID of the device + nickname (Optional[str]): The Nickname of the device + + Returns: + S200BHandler: Handler for the [S200B](https://www.tapo.com/en/search/?q=S200B) devices. + + Example: + ```python + # Connect to the hub + client = ApiClient("tapo-username@example.com", "tapo-password") + hub = await client.h100("192.168.1.100") + + # Get a handler for the child device + device = await hub.s200b(device_id="0000000000000000000000000000000000000000") + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + ``` + """ + + async def t100( + self, device_id: Optional[str] = None, nickname: Optional[str] = None + ) -> T100Handler: + """Returns a `T100Handler` for the device matching the provided `device_id` or `nickname`. + + Args: + device_id (Optional[str]): The Device ID of the device + nickname (Optional[str]): The Nickname of the device + + Returns: + T100Handler: Handler for the [T100](https://www.tapo.com/en/search/?q=T100) devices. + + Example: + ```python + # Connect to the hub + client = ApiClient("tapo-username@example.com", "tapo-password") + hub = await client.h100("192.168.1.100") + + # Get a handler for the child device + device = await hub.t100(device_id="0000000000000000000000000000000000000000") + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + ``` + """ + + async def t110( + self, device_id: Optional[str] = None, nickname: Optional[str] = None + ) -> T110Handler: + """Returns a `T110Handler` for the device matching the provided `device_id` or `nickname`. + + Args: + device_id (Optional[str]): The Device ID of the device + nickname (Optional[str]): The Nickname of the device + + Returns: + T110Handler: Handler for the [T110](https://www.tapo.com/en/search/?q=T110) devices. + + Example: + ```python + # Connect to the hub + client = ApiClient("tapo-username@example.com", "tapo-password") + hub = await client.h100("192.168.1.100") + + # Get a handler for the child device + device = await hub.t110(device_id="0000000000000000000000000000000000000000") + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + ``` + """ + + async def t300( + self, device_id: Optional[str] = None, nickname: Optional[str] = None + ) -> T300Handler: + """Returns a `T300Handler` for the device matching the provided `device_id` or `nickname`. + + Args: + device_id (Optional[str]): The Device ID of the device + nickname (Optional[str]): The Nickname of the device + + Returns: + T300Handler: Handler for the [T300](https://www.tapo.com/en/search/?q=T300) devices. + + Example: + ```python + # Connect to the hub + client = ApiClient("tapo-username@example.com", "tapo-password") + hub = await client.h100("192.168.1.100") + + # Get a handler for the child device + device = await hub.t300(device_id="0000000000000000000000000000000000000000") + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + ``` + """ + + async def t310( + self, device_id: Optional[str] = None, nickname: Optional[str] = None + ) -> T31XHandler: + """Returns a `T31XHandler` for the device matching the provided `device_id` or `nickname`. + Args: + device_id (Optional[str]): The Device ID of the device + nickname (Optional[str]): The Nickname of the device + + Returns: + T31XHandler: Handler for the [T310](https://www.tapo.com/en/search/?q=T310) + and [T315](https://www.tapo.com/en/search/?q=T315) devices. + + Example: + ```python + # Connect to the hub + client = ApiClient("tapo-username@example.com", "tapo-password") + hub = await client.h100("192.168.1.100") + + # Get a handler for the child device + device = await hub.t310(device_id="0000000000000000000000000000000000000000") + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + ``` + """ + + async def t315( + self, device_id: Optional[str] = None, nickname: Optional[str] = None + ) -> T31XHandler: + """Returns a `T31XHandler` for the device matching the provided `device_id` or `nickname`. + Args: + device_id (Optional[str]): The Device ID of the device + nickname (Optional[str]): The Nickname of the device + + Returns: + T31XHandler: Handler for the [T310](https://www.tapo.com/en/search/?q=T310) + and [T315](https://www.tapo.com/en/search/?q=T315) devices. + + Example: + ```python + # Connect to the hub + client = ApiClient("tapo-username@example.com", "tapo-password") + hub = await client.h100("192.168.1.100") + + # Get a handler for the child device + device = await hub.t315(device_id="0000000000000000000000000000000000000000") + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + ``` + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/ke100_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/ke100_handler.pyi new file mode 100644 index 0000000..7bdc2dd --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/ke100_handler.pyi @@ -0,0 +1,69 @@ +from tapo.requests import TemperatureUnitKE100 +from tapo.responses import KE100Result + +class KE100Handler: + """Handler for the [KE100](https://www.tp-link.com/en/search/?q=KE100) devices.""" + + async def get_device_info(self) -> KE100Result: + """Returns *device info* as `KE100Result`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `KE100Handler.get_device_info_json`. + + Returns: + KE100Result: Device info of Tapo KE100 thermostatic radiator valve (TRV). + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def set_child_protection(self, on: bool) -> None: + """Sets *child protection* on the device to *on* or *off*. + + Args: + on (bool) + """ + + async def set_frost_protection(self, on: bool) -> None: + """Sets *frost protection* on the device to *on* or *off*. + + Args: + on (bool) + """ + + async def set_max_control_temperature(self, value: int, unit: TemperatureUnitKE100) -> None: + """Sets the *maximum control temperature*. + + Args: + value (int) + unit (TemperatureUnitKE100) + """ + + async def set_min_control_temperature(self, value: int, unit: TemperatureUnitKE100) -> None: + """Sets the *minimum control temperature*. + + Args: + value (int) + unit (TemperatureUnitKE100) + """ + + async def set_target_temperature(self, value: int, unit: TemperatureUnitKE100) -> None: + """Sets the *target temperature*. + + Args: + value (int): between `min_control_temperature` and `max_control_temperature` + unit (TemperatureUnitKE100) + """ + + async def set_temperature_offset(self, value: int, unit: TemperatureUnitKE100) -> None: + """Sets the *temperature offset*. + + Args: + value (int): between -10 and 10 + unit (TemperatureUnitKE100) + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/light_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/light_handler.pyi new file mode 100644 index 0000000..2d6eefb --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/light_handler.pyi @@ -0,0 +1,54 @@ +from tapo.device_management_ext import DeviceManagementExt +from tapo.responses import DeviceInfoLightResult, DeviceUsageResult + +class LightHandler(DeviceManagementExt): + """Handler for the [L510](https://www.tapo.com/en/search/?q=L510), + [L520](https://www.tapo.com/en/search/?q=L520) and + [L610](https://www.tapo.com/en/search/?q=L610) devices.""" + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def refresh_session(self) -> None: + """Refreshes the authentication session.""" + + async def on(self) -> None: + """Turns *on* the device.""" + + async def off(self) -> None: + """Turns *off* the device.""" + + async def get_device_info(self) -> DeviceInfoLightResult: + """Returns *device info* as `DeviceInfoLightResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `LightHandler.get_device_info_json`. + + Returns: + DeviceInfoLightResult: Device info of Tapo L510, L520 and L610. + Superset of `GenericDeviceInfoResult`. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_device_usage(self) -> DeviceUsageResult: + """Returns *device usage* as `DeviceUsageResult`. + + Returns: + DeviceUsageResult: Contains the time usage. + """ + + async def set_brightness(self, brightness: int) -> None: + """Sets the *brightness* and turns *on* the device. + + Args: + brightness (int): between 1 and 100 + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/plug_energy_monitoring_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/plug_energy_monitoring_handler.pyi new file mode 100644 index 0000000..15d96f3 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/plug_energy_monitoring_handler.pyi @@ -0,0 +1,98 @@ +from datetime import datetime + +from tapo.device_management_ext import DeviceManagementExt +from tapo.requests import EnergyDataInterval, PowerDataInterval +from tapo.responses import ( + CurrentPowerResult, + DeviceInfoPlugEnergyMonitoringResult, + DeviceUsageEnergyMonitoringResult, + EnergyDataResult, + EnergyUsageResult, + PowerDataResult, +) + +class PlugEnergyMonitoringHandler(DeviceManagementExt): + """Handler for the [P110](https://www.tapo.com/en/search/?q=P110), + [P110M](https://www.tapo.com/en/search/?q=P110M) and + [P115](https://www.tapo.com/en/search/?q=P115) devices. + """ + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def refresh_session(self) -> None: + """Refreshes the authentication session.""" + + async def on(self) -> None: + """Turns *on* the device.""" + + async def off(self) -> None: + """Turns *off* the device.""" + + async def get_device_info(self) -> DeviceInfoPlugEnergyMonitoringResult: + """Returns *device info* as `DeviceInfoPlugEnergyMonitoringResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `PlugEnergyMonitoringHandler.get_device_info_json`. + + Returns: + DeviceInfoPlugEnergyMonitoringResult: Device info of P110, P110M and P115. + Superset of `GenericDeviceInfoResult`. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_current_power(self) -> CurrentPowerResult: + """Returns *current power* as `CurrentPowerResult`. + + Returns: + CurrentPowerResult: Contains the current power reading of the device. + """ + + async def get_device_usage(self) -> DeviceUsageEnergyMonitoringResult: + """Returns *device usage* as `DeviceUsageResult`. + + Returns: + DeviceUsageEnergyMonitoringResult: + Contains the time usage, the power consumption, and the energy savings of the device. + """ + + async def get_energy_usage(self) -> EnergyUsageResult: + """Returns *energy usage* as `EnergyUsageResult`. + + Returns: + EnergyUsageResult: + Contains local time, current power and the energy usage and runtime for today and for the current month. + """ + + async def get_energy_data( + self, + interval: EnergyDataInterval, + start_date: datetime, + end_date: datetime = None, + ) -> EnergyDataResult: + """Returns *energy data* as `EnergyDataResult`. + + Returns: + EnergyDataResult: Energy data result for the requested `EnergyDataInterval`. + """ + + async def get_power_data( + self, + interval: PowerDataInterval, + start_date_time: datetime, + end_date_time: datetime, + ) -> PowerDataResult: + """Returns *power data* as `PowerDataResult`. + + Returns: + PowerDataResult: Power data result for the requested `PowerDataInterval`. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/plug_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/plug_handler.pyi new file mode 100644 index 0000000..9e1bc18 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/plug_handler.pyi @@ -0,0 +1,47 @@ +from tapo.device_management_ext import DeviceManagementExt +from tapo.responses import DeviceInfoPlugResult, DeviceUsageResult + +class PlugHandler(DeviceManagementExt): + """Handler for the [P100](https://www.tapo.com/en/search/?q=P100) and + [P105](https://www.tapo.com/en/search/?q=P105) devices. + """ + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def refresh_session(self) -> None: + """Refreshes the authentication session.""" + + async def on(self) -> None: + """Turns *on* the device.""" + + async def off(self) -> None: + """Turns *off* the device.""" + + async def get_device_info(self) -> DeviceInfoPlugResult: + """Returns *device info* as `DeviceInfoPlugResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `PlugHandler.get_device_info_json`. + + Returns: + DeviceInfoPlugResult: Device info of Tapo P100 and P105. + Superset of `GenericDeviceInfoResult`. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_device_usage(self) -> DeviceUsageResult: + """Returns *device usage* as `DeviceUsageResult`. + + Returns: + DeviceUsageResult: Contains the time usage. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_energy_monitoring_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_energy_monitoring_handler.pyi new file mode 100644 index 0000000..346761c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_energy_monitoring_handler.pyi @@ -0,0 +1,96 @@ +from typing import List, Optional + +from tapo import PowerStripPlugEnergyMonitoringHandler +from tapo.device_management_ext import DeviceManagementExt +from tapo.responses import DeviceInfoPowerStripResult, PowerStripPlugEnergyMonitoringResult + +class PowerStripEnergyMonitoringHandler(DeviceManagementExt): + """Handler for the [P304M](https://www.tp-link.com/uk/search/?q=P304M) and + [P316M](https://www.tp-link.com/us/search/?q=P316M) devices. + """ + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def refresh_session(self) -> None: + """Refreshes the authentication session.""" + + async def get_device_info(self) -> DeviceInfoPowerStripResult: + """Returns *device info* as `DeviceInfoPowerStripResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `PowerStripEnergyMonitoringHandler.get_device_info_json`. + + Returns: + DeviceInfoPowerStripResult: Device info of Tapo P300, P304M, P306 and P316M. Superset of `DeviceInfoGenericResult`. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_child_device_list( + self, + ) -> List[PowerStripPlugEnergyMonitoringResult]: + """Returns *child device list* as `List[PowerStripPlugEnergyMonitoringResult]`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `PowerStripEnergyMonitoringHandler.get_child_device_list_json`. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_child_device_list_json(self) -> dict: + """Returns *child device list* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_child_device_component_list_json(self) -> dict: + """Returns *child device component list* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def plug( + self, + device_id: Optional[str] = None, + nickname: Optional[str] = None, + position: Optional[int] = None, + ) -> PowerStripPlugEnergyMonitoringHandler: + """Returns a `PowerStripPlugEnergyMonitoringHandler` for the device matching the provided `device_id`, `nickname`, or `position`. + + Args: + device_id (Optional[str]): The Device ID of the device + nickname (Optional[str]): The Nickname of the device + position (Optional[str]): The Position of the device + + Returns: + PowerStripPlugEnergyMonitoringHandler: Handler for the [P304M](https://www.tp-link.com/uk/search/?q=P304M) and + [P316M](https://www.tp-link.com/us/search/?q=P316M) child plugs. + + Example: + ```python + # Connect to the hub + client = ApiClient("tapo-username@example.com", "tapo-password") + power_strip = await client.p304("192.168.1.100") + + # Get a handler for the child device + device = await power_strip.plug(device_id="0000000000000000000000000000000000000000") + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + ``` + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_handler.pyi new file mode 100644 index 0000000..3ed910b --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_handler.pyi @@ -0,0 +1,96 @@ +from typing import List, Optional + +from tapo import PowerStripPlugHandler +from tapo.device_management_ext import DeviceManagementExt +from tapo.responses import DeviceInfoPowerStripResult, PowerStripPlugResult + +class PowerStripHandler(DeviceManagementExt): + """Handler for the [P300](https://www.tp-link.com/en/search/?q=P300) and + [P306](https://www.tp-link.com/us/search/?q=P306) devices. + """ + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def refresh_session(self) -> None: + """Refreshes the authentication session.""" + + async def get_device_info(self) -> DeviceInfoPowerStripResult: + """Returns *device info* as `DeviceInfoPowerStripResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `PowerStripHandler.get_device_info_json`. + + Returns: + DeviceInfoPowerStripResult: Device info of Tapo P300, P304M, P306 and P316M. Superset of `DeviceInfoGenericResult`. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_child_device_list( + self, + ) -> List[PowerStripPlugResult]: + """Returns *child device list* as `List[PowerStripPlugResult]`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `PowerStripHandler.get_child_device_list_json`. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_child_device_list_json(self) -> dict: + """Returns *child device list* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_child_device_component_list_json(self) -> dict: + """Returns *child device component list* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def plug( + self, + device_id: Optional[str] = None, + nickname: Optional[str] = None, + position: Optional[int] = None, + ) -> PowerStripPlugHandler: + """Returns a `PowerStripPlugHandler` for the device matching the provided `device_id`, `nickname`, or `position`. + + Args: + device_id (Optional[str]): The Device ID of the device + nickname (Optional[str]): The Nickname of the device + position (Optional[str]): The Position of the device + + Returns: + PowerStripPlugHandler: Handler for the [P300](https://www.tp-link.com/en/search/?q=P300) and + [P306](https://www.tp-link.com/us/search/?q=P306) child plugs. + + Example: + ```python + # Connect to the hub + client = ApiClient("tapo-username@example.com", "tapo-password") + power_strip = await client.p300("192.168.1.100") + + # Get a handler for the child device + device = await power_strip.plug(device_id="0000000000000000000000000000000000000000") + + # Get the device info of the child device + device_info = await device.get_device_info() + print(f"Device info: {device_info.to_dict()}") + ``` + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_plug_energy_monitoring_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_plug_energy_monitoring_handler.pyi new file mode 100644 index 0000000..55bf587 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_plug_energy_monitoring_handler.pyi @@ -0,0 +1,92 @@ +from datetime import datetime + +from tapo.requests import EnergyDataInterval, PowerDataInterval +from tapo.responses import ( + CurrentPowerResult, + DeviceUsageEnergyMonitoringResult, + EnergyDataResult, + EnergyUsageResult, + PowerDataResult, + PowerStripPlugEnergyMonitoringResult, +) + +class PowerStripPlugEnergyMonitoringHandler: + """Handler for the [P304M](https://www.tp-link.com/uk/search/?q=P304M) and + [P316M](https://www.tp-link.com/us/search/?q=P316M) child plugs. + """ + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def on(self) -> None: + """Turns *on* the device.""" + + async def off(self) -> None: + """Turns *off* the device.""" + + async def get_device_info(self) -> PowerStripPlugEnergyMonitoringResult: + """Returns *device info* as `PowerStripPlugEnergyMonitoringResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `PowerStripPlugEnergyMonitoringHandler.get_device_info_json`. + + Returns: + PowerStripPlugEnergyMonitoringResult: P304M and P316M power strip child plugs. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_current_power(self) -> CurrentPowerResult: + """Returns *current power* as `CurrentPowerResult`. + + Returns: + CurrentPowerResult: Contains the current power reading of the device. + """ + + async def get_device_usage(self) -> DeviceUsageEnergyMonitoringResult: + """Returns *device usage* as `DeviceUsageResult`. + + Returns: + DeviceUsageEnergyMonitoringResult: + Contains the time usage, the power consumption, and the energy savings of the device. + """ + + async def get_energy_usage(self) -> EnergyUsageResult: + """Returns *energy usage* as `EnergyUsageResult`. + + Returns: + EnergyUsageResult: + Contains local time, current power and the energy usage and runtime for today and for the current month. + """ + + async def get_energy_data( + self, + interval: EnergyDataInterval, + start_date: datetime, + end_date: datetime = None, + ) -> EnergyDataResult: + """Returns *energy data* as `EnergyDataResult`. + + Returns: + EnergyDataResult: Energy data result for the requested `EnergyDataInterval`. + """ + + async def get_power_data( + self, + interval: PowerDataInterval, + start_date_time: datetime, + end_date_time: datetime, + ) -> PowerDataResult: + """Returns *power data* as `PowerDataResult`. + + Returns: + PowerDataResult: Power data result for the requested `PowerDataInterval`. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_plug_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_plug_handler.pyi new file mode 100644 index 0000000..75ef91b --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/power_strip_plug_handler.pyi @@ -0,0 +1,35 @@ +from tapo.responses import PowerStripPlugResult + +class PowerStripPlugHandler: + """Handler for the [P300](https://www.tp-link.com/en/search/?q=P300) and + [P306](https://www.tp-link.com/us/search/?q=P306) child plugs. + """ + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def on(self) -> None: + """Turns *on* the device.""" + + async def off(self) -> None: + """Turns *off* the device.""" + + async def get_device_info(self) -> PowerStripPlugResult: + """Returns *device info* as `PowerStripPlugResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `PowerStripPlugHandler.get_device_info_json`. + + Returns: + PowerStripPlugResult: P300 and P306 power strip child plugs. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/__init__.py b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/__init__.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/__init__.pyi new file mode 100644 index 0000000..3e8ce08 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/__init__.pyi @@ -0,0 +1,6 @@ +from .energy_data_interval import * +from .play_alarm import * +from .power_data_interval import * +from .set_device_info import * + +from tapo.responses import TemperatureUnitKE100 as TemperatureUnitKE100 diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/energy_data_interval.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/energy_data_interval.pyi new file mode 100644 index 0000000..38a3cfc --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/energy_data_interval.pyi @@ -0,0 +1,15 @@ +from enum import Enum + +class EnergyDataInterval(str, Enum): + """Energy data interval.""" + + Hourly = "Hourly" + """Hourly interval. `start_date` and `end_date` are an inclusive interval + that must not be greater than 8 days. + """ + + Daily = "Daily" + """Daily interval. `start_date` must be the first day of a quarter.""" + + Monthly = "Monthly" + """Monthly interval. `start_date` must be the first day of a year.""" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/play_alarm.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/play_alarm.pyi new file mode 100644 index 0000000..768ba87 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/play_alarm.pyi @@ -0,0 +1,103 @@ +from enum import Enum + +class AlarmVolume(str, Enum): + """The volume of the alarm. + For the H100, this is a fixed list of volume levels.""" + + Default = "Default" + """Use the default volume for the hub.""" + + Mute = "Mute" + """Mute the audio output from the alarm. + This causes the alarm to be shown as triggered in the Tapo App + without an audible sound, and makes the `in_alarm` property + in `DeviceInfoHubResult` return as `True`.""" + + Low = "Low" + """Lowest volume.""" + + Normal = "Normal" + """Normal volume. This is the default.""" + + High = "High" + """Highest volume.""" + +class AlarmRingtone(str, Enum): + """The ringtone of a H100 alarm.""" + + Alarm1 = "Alarm1" + """Alarm 1""" + + Alarm2 = "Alarm2" + """Alarm 2""" + + Alarm3 = "Alarm3" + """Alarm 3""" + + Alarm4 = "Alarm4" + """Alarm 4""" + + Alarm5 = "Alarm5" + """Alarm 5""" + + Connection1 = "Connection1" + """Connection 1""" + + Connection2 = "Connection2" + """Connection 2""" + + DoorbellRing1 = "DoorbellRing1" + """Doorbell Ring 1""" + + DoorbellRing2 = "DoorbellRing2" + """Doorbell Ring 2""" + + DoorbellRing3 = "DoorbellRing3" + """Doorbell Ring 3""" + + DoorbellRing4 = "DoorbellRing4" + """Doorbell Ring 4""" + + DoorbellRing5 = "DoorbellRing5" + """Doorbell Ring 5""" + + DoorbellRing6 = "DoorbellRing6" + """Doorbell Ring 6""" + + DoorbellRing7 = "DoorbellRing7" + """Doorbell Ring 7""" + + DoorbellRing8 = "DoorbellRing8" + """Doorbell Ring 8""" + + DoorbellRing9 = "DoorbellRing9" + """Doorbell Ring 9""" + + DoorbellRing10 = "DoorbellRing10" + """Doorbell Ring 10""" + + DrippingTap = "DrippingTap" + """Dripping Tap""" + + PhoneRing = "PhoneRing" + """Phone Ring""" + +class AlarmDuration(str, Enum): + """Controls how long the alarm plays for.""" + + Continuous = "Continuous" + """Play the alarm continuously until stopped.""" + + Once = "Once" + """Play the alarm once. + This is useful for previewing the audio. + + Limitations: + + The `in_alarm` field of `DeviceInfoHubResult` will not remain `True` for the + duration of the audio track. Each audio track has a different runtime. + + Has no observable affect when used in conjunction with `AlarmVolume.Mute`.""" + + Seconds = "Seconds" + """Play the alarm a number of seconds.""" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/power_data_interval.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/power_data_interval.pyi new file mode 100644 index 0000000..5ecec3c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/power_data_interval.pyi @@ -0,0 +1,16 @@ +from enum import Enum + +class PowerDataInterval(str, Enum): + """Power data interval.""" + + Every5Minutes = "5min" + """Every 5 minutes interval. `start_date_time` and `end_date_time` describe an exclusive interval. + If the result would yield more than 144 entries (i.e. 12 hours), + the `end_date_time` will be adjusted to an earlier date and time. + """ + + Hourly = "Hourly" + """Hourly interval. `start_date_time` and `end_date_time` describe an exclusive interval. + If the result would yield more than 144 entries (i.e. 6 days), + the `end_date_time` will be adjusted to an earlier date and time. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/__init__.py b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/__init__.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/__init__.pyi new file mode 100644 index 0000000..634b244 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/__init__.pyi @@ -0,0 +1,3 @@ +from .color import * +from .color_light import * +from .lighting_effect import * diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/color.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/color.pyi new file mode 100644 index 0000000..e78ea15 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/color.pyi @@ -0,0 +1,50 @@ +from enum import Enum +from typing import Tuple + +class Color(str, Enum): + """List of preset colors as defined in the Google Home app.""" + + CoolWhite = "CoolWhite" + Daylight = "Daylight" + Ivory = "Ivory" + WarmWhite = "WarmWhite" + Incandescent = "Incandescent" + Candlelight = "Candlelight" + Snow = "Snow" + GhostWhite = "GhostWhite" + AliceBlue = "AliceBlue" + LightGoldenrod = "LightGoldenrod" + LemonChiffon = "LemonChiffon" + AntiqueWhite = "AntiqueWhite" + Gold = "Gold" + Peru = "Peru" + Chocolate = "Chocolate" + SandyBrown = "SandyBrown" + Coral = "Coral" + Pumpkin = "Pumpkin" + Tomato = "Tomato" + Vermilion = "Vermilion" + OrangeRed = "OrangeRed" + Pink = "Pink" + Crimson = "Crimson" + DarkRed = "DarkRed" + HotPink = "HotPink" + Smitten = "Smitten" + MediumPurple = "MediumPurple" + BlueViolet = "BlueViolet" + Indigo = "Indigo" + LightSkyBlue = "LightSkyBlue" + CornflowerBlue = "CornflowerBlue" + Ultramarine = "Ultramarine" + DeepSkyBlue = "DeepSkyBlue" + Azure = "Azure" + NavyBlue = "NavyBlue" + LightTurquoise = "LightTurquoise" + Aquamarine = "Aquamarine" + Turquoise = "Turquoise" + LightGreen = "LightGreen" + Lime = "Lime" + ForestGreen = "ForestGreen" + + def get_color_config(self) -> Tuple[int, int, int]: + """Get the `hue`, `saturation`, and `color_temperature` of the color.""" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/color_light.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/color_light.pyi new file mode 100644 index 0000000..aef4cc9 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/color_light.pyi @@ -0,0 +1,65 @@ +from typing import Union + +from tapo.color_light_handler import ColorLightHandler +from tapo.requests import Color +from tapo.rgb_light_strip_handler import RgbLightStripHandler + +class ColorLightSetDeviceInfoParams: + """Builder that is used by the `ColorLightHandler.set` API to set + multiple properties in a single request. + """ + + def on(self) -> ColorLightSetDeviceInfoParams: + """Turns *on* the device. + `ColorLightSetDeviceInfoParams.send` must be called at the end to apply the changes. + """ + + def off(self) -> ColorLightSetDeviceInfoParams: + """Turns *off* the device. + `ColorLightSetDeviceInfoParams.send` must be called at the end to apply the changes. + """ + + def brightness(self, brightness: int) -> ColorLightSetDeviceInfoParams: + """Sets the *brightness*. + `ColorLightSetDeviceInfoParams.send` must be called at the end to apply the changes. + The device will also be turned *on*, unless `ColorLightSetDeviceInfoParams.off` is called. + + Args: + brightness (int): between 1 and 100 + """ + + def color(self, color: Color) -> ColorLightSetDeviceInfoParams: + """Sets the *color*. + `ColorLightSetDeviceInfoParams.send` must be called at the end to apply the changes. + The device will also be turned *on*, unless `ColorLightSetDeviceInfoParams.off` is called. + + Args: + color (Color): one of `tapo.Color` as defined in the Google Home app. + """ + + def hue_saturation(self, hue: int, saturation: int) -> ColorLightSetDeviceInfoParams: + """Sets the *hue* and *saturation*. + `ColorLightSetDeviceInfoParams.send` must be called at the end to apply the changes. + The device will also be turned *on*, unless `ColorLightSetDeviceInfoParams.off` is called. + + Args: + hue (int): between 0 and 360 + saturation (int): between 1 and 100 + """ + + def color_temperature(self, color_temperature: int) -> ColorLightSetDeviceInfoParams: + """ + Sets the *color temperature*. + `ColorLightSetDeviceInfoParams.send` must be called at the end to apply the changes. + The device will also be turned *on*, unless `ColorLightSetDeviceInfoParams.off` is called. + + Args: + color_temperature (int): between 2500 and 6500 + """ + + async def send(self, handler: Union[ColorLightHandler, RgbLightStripHandler]) -> None: + """Performs a request to apply the changes to the device. + + Args: + handler (`ColorLightHandler` | `RgbLightStripHandler`) + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/lighting_effect.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/lighting_effect.pyi new file mode 100644 index 0000000..8df568e --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/requests/set_device_info/lighting_effect.pyi @@ -0,0 +1,92 @@ +from enum import Enum +from typing import List, Optional, Tuple + +class LightingEffectType(str, Enum): + Sequence = "Sequence" + Random = "Random" + Pulse = "Pulse" + Static = "Static" + +class LightingEffect: + brightness: int + is_custom: bool + display_colors: List[Tuple[int, int, int]] + """The colors that will be displayed in the Tapo app.""" + enabled: bool + id: str + name: str + type: str + backgrounds: Optional[List[Tuple[int, int, int]]] + brightness_range: Optional[List[Tuple[int, int]]] + direction: Optional[int] + duration: Optional[int] + expansion_strategy: Optional[int] + fade_off: Optional[int] + hue_range: Optional[List[Tuple[int, int]]] + init_states: Optional[List[Tuple[int, int, int]]] + random_seed: Optional[int] + repeat_times: Optional[int] + run_time: Optional[int] + saturation_range: Optional[List[Tuple[int, int]]] + segment_length: Optional[int] + segments: Optional[List[int]] + sequence: Optional[List[Tuple[int, int, int]]] + spread: Optional[int] + transition: Optional[int] + transition_range: Optional[List[Tuple[int, int]]] + transition_sequence: Optional[List[int]] + + def __init__( + self, + name: str, + type: LightingEffectType, + is_custom: bool, + enabled: bool, + brightness: int, + display_colors: List[Tuple[int, int, int]], + ) -> None: ... + def with_brightness(self, brightness: int) -> LightingEffect: ... + def with_is_custom(self, is_custom: bool) -> LightingEffect: ... + def with_display_colors(self, display_colors: List[Tuple[int, int, int]]) -> LightingEffect: ... + def with_enabled(self, enabled: bool) -> LightingEffect: ... + def with_id(self, id: str) -> LightingEffect: ... + def with_name(self, name: str) -> LightingEffect: ... + def with_type(self, type: LightingEffectType) -> LightingEffect: ... + def with_backgrounds(self, backgrounds: List[Tuple[int, int, int]]) -> LightingEffect: ... + def with_brightness_range(self, brightness_range: Tuple[int, int]) -> LightingEffect: ... + def with_direction(self, direction: int) -> LightingEffect: ... + def with_duration(self, duration: int) -> LightingEffect: ... + def with_expansion_strategy(self, expansion_strategy: int) -> LightingEffect: ... + def with_fade_off(self, fade_off: int) -> LightingEffect: ... + def with_hue_range(self, hue_range: Tuple[int, int]) -> LightingEffect: ... + def with_init_states(self, init_states: List[Tuple[int, int, int]]) -> LightingEffect: ... + def with_random_seed(self, random_seed: int) -> LightingEffect: ... + def with_repeat_times(self, repeat_times: int) -> LightingEffect: ... + def with_run_time(self, run_time: int) -> LightingEffect: ... + def with_saturation_range(self, saturation_range: Tuple[int, int]) -> LightingEffect: ... + def with_segment_length(self, segment_length: int) -> LightingEffect: ... + def with_segments(self, segments: List[int]) -> LightingEffect: ... + def with_sequence(self, sequence: List[Tuple[int, int, int]]) -> LightingEffect: ... + def with_spread(self, spread: int) -> LightingEffect: ... + def with_transition(self, transition: int) -> LightingEffect: ... + def with_transition_range(self, transition_range: Tuple[int, int]) -> LightingEffect: ... + def with_transition_sequence(self, transition_sequence: List[int]) -> LightingEffect: ... + +class LightingEffectPreset(str, Enum): + Aurora = "Aurora" + BubblingCauldron = "BubblingCauldron" + CandyCane = "CandyCane" + Christmas = "Christmas" + Flicker = "Flicker" + GrandmasChristmasLights = "GrandmasChristmasLights" + Hanukkah = "Hanukkah" + HauntedMansion = "HauntedMansion" + Icicle = "Icicle" + Lightning = "Lightning" + Ocean = "Ocean" + Rainbow = "Rainbow" + Raindrop = "Raindrop" + Spring = "Spring" + Sunrise = "Sunrise" + Sunset = "Sunset" + Valentines = "Valentines" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/__init__.py b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/__init__.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/__init__.pyi new file mode 100644 index 0000000..c2e40dc --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/__init__.pyi @@ -0,0 +1,9 @@ +from .child_device_list_hub_result import * +from .child_device_list_power_strip_result import * +from .current_power_result import * +from .device_info_result import * +from .device_usage_energy_monitoring_result import * +from .device_usage_result import * +from .energy_data_result import * +from .energy_usage_result import * +from .power_data_result import * diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/__init__.py b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/__init__.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/__init__.pyi new file mode 100644 index 0000000..5815f32 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/__init__.pyi @@ -0,0 +1,10 @@ +from .hub_result import * +from .status import * +from .temperature_unit import * + +from .ke100_result import * +from .s200b_result import * +from .t100_result import * +from .t110_result import * +from .t300_result import * +from .t31x_result import * diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/hub_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/hub_result.pyi new file mode 100644 index 0000000..53a0040 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/hub_result.pyi @@ -0,0 +1,32 @@ +from tapo.responses.child_device_list_hub_result.status import Status + +class HubResult: + """Hub result. This is an abstract base class for all hub results.""" + + at_low_battery: bool + avatar: str + bind_count: int + category: str + device_id: str + fw_ver: str + hw_id: str + hw_ver: str + jamming_rssi: int + jamming_signal_level: int + mac: str + nickname: str + oem_id: str + parent_device_id: str + region: str + rssi: int + signal_level: int + specs: str + status: Status + type: str + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/ke100_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/ke100_result.pyi new file mode 100644 index 0000000..3e002d1 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/ke100_result.pyi @@ -0,0 +1,28 @@ +from enum import Enum + +from tapo.responses.child_device_list_hub_result.hub_result import HubResult + +class KE100Result(HubResult): + """Device info of Tapo KE100 thermostatic radiator valve (TRV). + + Specific properties: `temperature_unit`, `current_temperature`, `target_temperature`, + `min_control_temperature`, `max_control_temperature`, `temperature_offset`, + `child_protection_on`, `frost_protection_on`, `location`. + """ + + child_protection_on: bool + current_temperature: float + frost_protection_on: bool + location: str + max_control_temperature: int + min_control_temperature: int + target_temperature: float + temperature_offset: int + temperature_unit: TemperatureUnitKE100 + +class TemperatureUnitKE100(str, Enum): + """Temperature unit for KE100 devices. + Currently *Celsius* is the only unit supported by KE100. + """ + + Celsius = "Celsius" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/s200b_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/s200b_result.pyi new file mode 100644 index 0000000..c230574 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/s200b_result.pyi @@ -0,0 +1,12 @@ +from tapo.responses.child_device_list_hub_result.hub_result import HubResult + +class S200BResult(HubResult): + """Device info of Tapo S200B button switch. + + Specific properties: `report_interval`, `last_onboarding_timestamp`, `status_follow_edge`. + """ + + last_onboarding_timestamp: int + report_interval: int + """The time in seconds between each report.""" + status_follow_edge: bool diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/status.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/status.pyi new file mode 100644 index 0000000..fe57b73 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/status.pyi @@ -0,0 +1,7 @@ +from enum import Enum + +class Status(str, Enum): + """Device status.""" + + Online = "Online" + Offline = "Offline" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t100_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t100_result.pyi new file mode 100644 index 0000000..1bfec27 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t100_result.pyi @@ -0,0 +1,14 @@ +from tapo.responses.child_device_list_hub_result.hub_result import HubResult + +class T100Result(HubResult): + """Device info of Tapo T100 motion sensor. + + Specific properties: `detected`, `report_interval`, + `last_onboarding_timestamp`, `status_follow_edge`. + """ + + detected: bool + last_onboarding_timestamp: int + report_interval: int + """The time in seconds between each report.""" + status_follow_edge: bool diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t110_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t110_result.pyi new file mode 100644 index 0000000..14da412 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t110_result.pyi @@ -0,0 +1,14 @@ +from tapo.responses.child_device_list_hub_result.hub_result import HubResult + +class T110Result(HubResult): + """Device info of Tapo T110 contact sensor. + + Specific properties: `open`, `report_interval`, + `last_onboarding_timestamp`,`status_follow_edge`. + """ + + last_onboarding_timestamp: int + open: bool + report_interval: int + """The time in seconds between each report.""" + status_follow_edge: bool diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t300_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t300_result.pyi new file mode 100644 index 0000000..f9b370f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t300_result.pyi @@ -0,0 +1,23 @@ +from enum import Enum +from tapo.responses.child_device_list_hub_result.hub_result import HubResult + +class T300Result(HubResult): + """Device info of Tapo T300 water sensor. + + Specific properties: `in_alarm`, `water_leak_status`, `report_interval`, + `last_onboarding_timestamp`, `status_follow_edge`. + """ + + in_alarm: bool + last_onboarding_timestamp: int + report_interval: int + """The time in seconds between each report.""" + status_follow_edge: bool + water_leak_status: WaterLeakStatus + +class WaterLeakStatus(str, Enum): + """Water leak status.""" + + Normal = "Normal" + WaterDry = "WaterDry" + WaterLeak = "WaterLeak" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t31x_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t31x_result.pyi new file mode 100644 index 0000000..c3c5af5 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/t31x_result.pyi @@ -0,0 +1,71 @@ +from datetime import datetime +from typing import List + +from tapo.responses import HubResult, TemperatureUnit +from tapo.responses import TemperatureUnit + +class T31XResult(HubResult): + """Device info of Tapo T310 and T315 temperature and humidity sensors. + + Specific properties: `current_temperature`, `temperature_unit`, + `current_temperature_exception`, `current_humidity`, `current_humidity_exception`, + `report_interval`, `last_onboarding_timestamp`, `status_follow_edge`. + """ + + current_humidity_exception: int + """ + This value will be `0` when the current humidity is within the comfort zone. + When the current humidity value falls outside the comfort zone, this value + will be the difference between the current humidity and the lower or upper bound of the comfort zone. + """ + current_humidity: int + current_temperature_exception: int + """ + This value will be `0.0` when the current temperature is within the comfort zone. + When the current temperature value falls outside the comfort zone, this value + will be the difference between the current temperature and the lower or upper bound of the comfort zone. + """ + current_temperature: int + last_onboarding_timestamp: int + report_interval: int + """The time in seconds between each report.""" + status_follow_edge: bool + temperature_unit: TemperatureUnit + +class TemperatureHumidityRecords: + """Temperature and Humidity records for the last 24 hours at 15 minute intervals.""" + + datetime: datetime + """The datetime in UTC of when this response was generated.""" + records: List[TemperatureHumidityRecord] + temperature_unit: TemperatureUnit + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class TemperatureHumidityRecord: + """Temperature and Humidity record as an average over a 15 minute interval.""" + + datetime: datetime + """Record's DateTime in UTC.""" + humidity_exception: int + """This value will be `0` when the current humidity is within the comfort zone. + When the current humidity value falls outside the comfort zone, this value + will be the difference between the current humidity and the lower or upper bound of the comfort zone.""" + humidity: int + temperature_exception: float + """This value will be `0.0` when the current temperature is within the comfort zone. + When the current temperature value falls outside the comfort zone, this value + will be the difference between the current temperature and the lower or upper bound of the comfort zone.""" + temperature: float + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/temperature_unit.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/temperature_unit.pyi new file mode 100644 index 0000000..8fd9ccb --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_hub_result/temperature_unit.pyi @@ -0,0 +1,7 @@ +from enum import Enum + +class TemperatureUnit(str, Enum): + """Temperature unit.""" + + Celsius = "Celsius" + Fahrenheit = "Fahrenheit" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/__init__.py b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/__init__.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/__init__.pyi new file mode 100644 index 0000000..2473dc1 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/__init__.pyi @@ -0,0 +1,2 @@ +from .power_strip_plug_result import * +from .power_strip_plug_energy_monitoring_result import * diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/power_strip_plug_energy_monitoring_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/power_strip_plug_energy_monitoring_result.pyi new file mode 100644 index 0000000..33fb9bb --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/power_strip_plug_energy_monitoring_result.pyi @@ -0,0 +1,63 @@ +from enum import Enum +from typing import Optional, Union + +from tapo.responses.device_info_result.default_plug_state import Custom, LastStates +from tapo.responses.device_info_result.power_status import ( + ChargingStatus, + OvercurrentStatus, + OverheatStatus, + PowerProtectionStatus, +) + +class PowerStripPlugEnergyMonitoringResult: + """P304M and P316M power strip child plugs. + + Specific properties: `auto_off_remain_time`, `auto_off_status`, + `bind_count`, `default_states`, `charging_status`, `is_usb`, + `overcurrent_status`, `overheat_status`, `position`, + `power_protection_status`, `slot_number`. + """ + + auto_off_remain_time: int + auto_off_status: AutoOffStatus + avatar: str + bind_count: int + category: str + default_states: Union[LastStates, Custom] + charging_status: ChargingStatus + device_id: str + device_on: bool + fw_id: str + fw_ver: str + has_set_location_info: bool + hw_id: str + hw_ver: str + is_usb: bool + latitude: Optional[int] + longitude: Optional[int] + mac: str + model: str + nickname: str + oem_id: str + on_time: int + """The time in seconds this device has been ON since the last state change (On/Off).""" + original_device_id: str + overcurrent_status: OvercurrentStatus + overheat_status: Optional[OverheatStatus] + position: int + power_protection_status: PowerProtectionStatus + region: Optional[str] + slot_number: int + status_follow_edge: bool + type: str + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class AutoOffStatus(str, Enum): + On = "on" + Off = "off" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/power_strip_plug_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/power_strip_plug_result.pyi new file mode 100644 index 0000000..59641af --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/child_device_list_power_strip_result/power_strip_plug_result.pyi @@ -0,0 +1,52 @@ +from enum import Enum +from typing import Optional, Union + +from tapo.responses.device_info_result.default_plug_state import Custom, LastStates +from tapo.responses.device_info_result.power_status import OverheatStatus + +class PowerStripPlugResult: + """P300 and P306 power strip child plugs. + + Specific properties: `auto_off_remain_time`, `auto_off_status`, + `bind_count`, `default_states`, `overheat_status`, `position`, `slot_number`. + """ + + auto_off_remain_time: int + auto_off_status: AutoOffStatus + avatar: str + bind_count: int + category: str + default_states: Union[LastStates, Custom] + device_id: str + device_on: bool + fw_id: str + fw_ver: str + has_set_location_info: bool + hw_id: str + hw_ver: str + latitude: Optional[int] + longitude: Optional[int] + mac: str + model: str + nickname: str + oem_id: str + on_time: int + """The time in seconds this device has been ON since the last state change (On/Off).""" + original_device_id: str + overheat_status: Optional[OverheatStatus] + position: int + region: Optional[str] + slot_number: int + status_follow_edge: bool + type: str + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class AutoOffStatus(str, Enum): + On = "on" + Off = "off" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/current_power_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/current_power_result.pyi new file mode 100644 index 0000000..998c788 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/current_power_result.pyi @@ -0,0 +1,12 @@ +class CurrentPowerResult: + """Contains the current power reading of the device.""" + + current_power: int + """Current power in Watts (W).""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/__init__.py b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/__init__.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/__init__.pyi new file mode 100644 index 0000000..d300780 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/__init__.pyi @@ -0,0 +1,14 @@ +from .color_light_result import * +from .default_state import * +from .generic_result import * +from .hub_result import * +from .light_result import * +from .plug_energy_monitoring_result import * +from .plug_result import * +from .power_status import * +from .power_strip_result import * +from .rgb_light_strip_result import * +from .rgbic_light_strip_result import * +from .default_plug_state import * + +from tapo.requests import LightingEffect as LightingEffect diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/color_light_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/color_light_result.pyi new file mode 100644 index 0000000..ba45898 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/color_light_result.pyi @@ -0,0 +1,64 @@ +from typing import Optional + +from tapo.responses.device_info_result.default_state import DefaultStateType + +class DeviceInfoColorLightResult: + """Device info of Tapo L530, L535 and L630. Superset of `GenericDeviceInfoResult`.""" + + device_id: str + type: str + model: str + hw_id: str + hw_ver: str + fw_id: str + fw_ver: str + oem_id: str + mac: str + ip: str + ssid: str + signal_level: int + rssi: int + specs: str + lang: str + device_on: bool + on_time: int + """The time in seconds this device has been ON since the last state change (On/Off).""" + nickname: str + avatar: str + has_set_location_info: bool + region: Optional[str] + latitude: Optional[float] + longitude: Optional[float] + time_diff: Optional[int] + + # Unique to this device + brightness: int + color_temp: int + default_states: DefaultColorLightState + """The default state of a device to be used when internet connectivity is lost after a power cut.""" + dynamic_light_effect_enable: bool + dynamic_light_effect_id: Optional[str] + hue: Optional[int] + overheated: bool + saturation: Optional[int] + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class DefaultColorLightState: + """Color Light Default State.""" + + type: DefaultStateType + state: ColorLightState + +class ColorLightState: + """Color Light State.""" + + brightness: int + hue: Optional[int] + saturation: Optional[int] + color_temp: int diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/default_plug_state.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/default_plug_state.pyi new file mode 100644 index 0000000..196e0af --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/default_plug_state.pyi @@ -0,0 +1,30 @@ +from typing import Type +from dataclasses import dataclass + +@dataclass +class LastStates: + __match_args__: tuple[str, ...] = () + +@dataclass +class Custom: + state: PlugState + + __match_args__ = ("state",) + +class DefaultPlugState: + """Plug Default State.""" + + LastStates: Type[LastStates] = LastStates + Custom: Type[Custom] = Custom + +class PlugState: + """Plug State.""" + + on: bool + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/default_state.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/default_state.pyi new file mode 100644 index 0000000..d9c40b1 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/default_state.pyi @@ -0,0 +1,19 @@ +from enum import Enum + +class DefaultBrightnessState: + """Default brightness state.""" + + type: DefaultStateType + value: int + +class DefaultStateType(str, Enum): + """The type of the default state.""" + + Custom = "custom" + LastStates = "last_states" + +class DefaultPowerType(str, Enum): + """The type of the default power state.""" + + AlwaysOn = "always_on" + LastStates = "last_states" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/generic_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/generic_result.pyi new file mode 100644 index 0000000..0857911 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/generic_result.pyi @@ -0,0 +1,37 @@ +from typing import Optional + +class DeviceInfoGenericResult: + """Device info of a Generic Tapo device.""" + + device_id: str + type: str + model: str + hw_id: str + hw_ver: str + fw_id: str + fw_ver: str + oem_id: str + mac: str + ip: str + ssid: str + signal_level: int + rssi: int + specs: str + lang: str + device_on: Optional[bool] + on_time: Optional[int] + """The time in seconds this device has been ON since the last state change (On/Off).""" + nickname: str + avatar: str + has_set_location_info: bool + region: Optional[str] + latitude: Optional[float] + longitude: Optional[float] + time_diff: Optional[int] + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/hub_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/hub_result.pyi new file mode 100644 index 0000000..27e5a9b --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/hub_result.pyi @@ -0,0 +1,39 @@ +from typing import Optional + +class DeviceInfoHubResult: + """Device info of Tapo H100. Superset of `GenericDeviceInfoResult`.""" + + device_id: str + type: str + model: str + hw_id: str + hw_ver: str + fw_id: str + fw_ver: str + oem_id: str + mac: str + ip: str + ssid: str + signal_level: int + rssi: int + specs: str + lang: str + nickname: str + avatar: str + has_set_location_info: bool + region: Optional[str] + latitude: Optional[float] + longitude: Optional[float] + time_diff: Optional[int] + + # Unique to this device + in_alarm_source: str + in_alarm: bool + overheated: bool + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/light_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/light_result.pyi new file mode 100644 index 0000000..78ec89c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/light_result.pyi @@ -0,0 +1,51 @@ +from typing import Optional + +from tapo.responses import DefaultBrightnessState, DefaultPowerType + +class DeviceInfoLightResult: + """Device info of Tapo L510, L520 and L610. Superset of `GenericDeviceInfoResult`.""" + + device_id: str + type: str + model: str + hw_id: str + hw_ver: str + fw_id: str + fw_ver: str + oem_id: str + mac: str + ip: str + ssid: str + signal_level: int + rssi: int + specs: str + lang: str + device_on: bool + on_time: int + """The time in seconds this device has been ON since the last state change (On/Off).""" + nickname: str + avatar: str + has_set_location_info: bool + region: Optional[str] + latitude: Optional[float] + longitude: Optional[float] + time_diff: Optional[int] + + # Unique to this device + brightness: int + default_states: DefaultLightState + """The default state of a device to be used when internet connectivity is lost after a power cut.""" + overheated: bool + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class DefaultLightState: + """Light Default State.""" + + brightness: DefaultBrightnessState + re_power_type: Optional[DefaultPowerType] diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/plug_energy_monitoring_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/plug_energy_monitoring_result.pyi new file mode 100644 index 0000000..49c979d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/plug_energy_monitoring_result.pyi @@ -0,0 +1,53 @@ +from typing import Optional, Union + +from tapo.responses.device_info_result.default_plug_state import Custom, LastStates +from tapo.responses.device_info_result.power_status import ( + ChargingStatus, + OvercurrentStatus, + OverheatStatus, + PowerProtectionStatus, +) + +class DeviceInfoPlugEnergyMonitoringResult: + """Device info of Tapo P110, P110M and P115. Superset of `GenericDeviceInfoResult`.""" + + device_id: str + type: str + model: str + hw_id: str + hw_ver: str + fw_id: str + fw_ver: str + oem_id: str + mac: str + ip: str + ssid: str + signal_level: int + rssi: int + specs: str + lang: str + device_on: bool + on_time: int + """The time in seconds this device has been ON since the last state change (On/Off).""" + nickname: str + avatar: str + has_set_location_info: bool + region: Optional[str] + latitude: Optional[float] + longitude: Optional[float] + time_diff: Optional[int] + + # Unique to this device + charging_status: ChargingStatus + default_states: Union[LastStates, Custom] + """The default state of a device to be used when internet connectivity is lost after a power cut.""" + overcurrent_status: OvercurrentStatus + overheat_status: Optional[OverheatStatus] + power_protection_status: PowerProtectionStatus + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/plug_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/plug_result.pyi new file mode 100644 index 0000000..51b4c3d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/plug_result.pyi @@ -0,0 +1,42 @@ +from typing import Optional, Union + +from tapo.responses.device_info_result.default_plug_state import Custom, LastStates + +class DeviceInfoPlugResult: + """Device info of Tapo P100 and P105. Superset of `GenericDeviceInfoResult`.""" + + device_id: str + type: str + model: str + hw_id: str + hw_ver: str + fw_id: str + fw_ver: str + oem_id: str + mac: str + ip: str + ssid: str + signal_level: int + rssi: int + specs: str + lang: str + device_on: bool + on_time: int + """The time in seconds this device has been ON since the last state change (On/Off).""" + nickname: str + avatar: str + has_set_location_info: bool + region: Optional[str] + latitude: Optional[float] + longitude: Optional[float] + time_diff: Optional[int] + + # Unique to this device + default_states: Union[LastStates, Custom] + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/power_status.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/power_status.pyi new file mode 100644 index 0000000..b2e976c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/power_status.pyi @@ -0,0 +1,18 @@ +from enum import Enum + +class ChargingStatus(str, Enum): + Finished = "finished" + Normal = "normal" + +class OvercurrentStatus(str, Enum): + Lifted = "lifted" + Normal = "normal" + +class OverheatStatus(str, Enum): + CoolDown = "cool_down" + Normal = "normal" + Overheated = "overheated" + +class PowerProtectionStatus(str, Enum): + Normal = "normal" + Overloaded = "overloaded" diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/power_strip_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/power_strip_result.pyi new file mode 100644 index 0000000..b531785 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/power_strip_result.pyi @@ -0,0 +1,33 @@ +from typing import Optional + +class DeviceInfoPowerStripResult: + """Device info of Tapo P300, P304M, P306 and P316M. Superset of `GenericDeviceInfoResult`.""" + + avatar: str + device_id: str + fw_id: str + fw_ver: str + has_set_location_info: bool + hw_id: str + hw_ver: str + ip: str + lang: str + latitude: Optional[float] + longitude: Optional[float] + mac: str + model: str + oem_id: str + region: Optional[str] + rssi: int + signal_level: int + specs: str + ssid: str + time_diff: Optional[int] + type: str + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/rgb_light_strip_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/rgb_light_strip_result.pyi new file mode 100644 index 0000000..c03b197 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/rgb_light_strip_result.pyi @@ -0,0 +1,63 @@ +from typing import List, Optional + +from tapo.responses.device_info_result.default_state import DefaultStateType + +class DeviceInfoRgbLightStripResult: + """Device info of Tapo L900. Superset of `GenericDeviceInfoResult`.""" + + device_id: str + type: str + model: str + hw_id: str + hw_ver: str + fw_id: str + fw_ver: str + oem_id: str + mac: str + ip: str + ssid: str + signal_level: int + rssi: int + specs: str + lang: str + device_on: bool + on_time: int + """The time in seconds this device has been ON since the last state change (On/Off).""" + nickname: str + avatar: str + has_set_location_info: bool + region: Optional[str] + latitude: Optional[float] + longitude: Optional[float] + time_diff: Optional[int] + + # Unique to this device + brightness: int + color_temp_range: List[int] + color_temp: int + default_states: DefaultRgbLightStripState + """The default state of a device to be used when internet connectivity is lost after a power cut.""" + hue: Optional[int] + overheated: bool + saturation: Optional[int] + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class DefaultRgbLightStripState: + """RGB Light Strip Default State.""" + + type: DefaultStateType + state: RgbLightStripState + +class RgbLightStripState: + """RGB Light Strip State.""" + + brightness: Optional[int] + hue: Optional[int] + saturation: Optional[int] + color_temp: Optional[int] diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/rgbic_light_strip_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/rgbic_light_strip_result.pyi new file mode 100644 index 0000000..be60139 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_info_result/rgbic_light_strip_result.pyi @@ -0,0 +1,65 @@ +from typing import List, Optional + +from tapo.responses.device_info_result.default_state import DefaultStateType +from tapo.requests.set_device_info.lighting_effect import LightingEffect + +class DeviceInfoRgbicLightStripResult: + """Device info of Tapo L920 and L930. Superset of `GenericDeviceInfoResult`.""" + + device_id: str + type: str + model: str + hw_id: str + hw_ver: str + fw_id: str + fw_ver: str + oem_id: str + mac: str + ip: str + ssid: str + signal_level: int + rssi: int + specs: str + lang: str + device_on: bool + on_time: int + """The time in seconds this device has been ON since the last state change (On/Off).""" + nickname: str + avatar: str + has_set_location_info: bool + region: Optional[str] + latitude: Optional[float] + longitude: Optional[float] + time_diff: Optional[int] + + # Unique to this device + brightness: int + color_temp_range: List[int] + color_temp: int + default_states: DefaultRgbicLightStripState + """The default state of a device to be used when internet connectivity is lost after a power cut.""" + hue: Optional[int] + overheated: bool + saturation: Optional[int] + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class DefaultRgbicLightStripState: + """RGB IC Light Strip Default State.""" + + type: DefaultStateType + state: RgbicLightStripState + +class RgbicLightStripState: + """RGB IC Light Strip State.""" + + brightness: Optional[int] + hue: Optional[int] + saturation: Optional[int] + color_temp: Optional[int] + lighting_effect: Optional[LightingEffect] diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_usage_energy_monitoring_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_usage_energy_monitoring_result.pyi new file mode 100644 index 0000000..d48d88c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_usage_energy_monitoring_result.pyi @@ -0,0 +1,18 @@ +from tapo.responses import UsageByPeriodResult + +class DeviceUsageEnergyMonitoringResult: + """Contains the time usage, the power consumption, and the energy savings of the device.""" + + time_usage: UsageByPeriodResult + """Time usage in minutes.""" + power_usage: UsageByPeriodResult + """Power usage in Watt Hours (Wh).""" + saved_power: UsageByPeriodResult + """Saved power in Watt Hours (Wh).""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_usage_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_usage_result.pyi new file mode 100644 index 0000000..e09d0b0 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/device_usage_result.pyi @@ -0,0 +1,24 @@ +from typing import Optional + +class UsageByPeriodResult: + """Usage by period result for today, the past 7 days, and the past 30 days.""" + + today: Optional[int] + """Today.""" + past7: Optional[int] + """Past 7 days.""" + past30: Optional[int] + """Past 30 days.""" + +class DeviceUsageResult: + """Contains the time usage.""" + + time_usage: UsageByPeriodResult + """Time usage in minutes.""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/energy_data_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/energy_data_result.pyi new file mode 100644 index 0000000..2185526 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/energy_data_result.pyi @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import List, Optional + +class EnergyDataResult: + """Energy data result for the requested `EnergyDataInterval`.""" + + local_time: datetime + """Local time of the device.""" + + start_date_time: datetime + """Start date and time of this result in UTC. + This value is provided in the `get_energy_data` request and is passed through. + Note that it may not align with the returned data if the method is used beyond its specified capabilities.""" + + entries: List[EnergyDataIntervalResult] + """List of energy data entries.""" + + interval_length: int + """Interval length in minutes.""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class EnergyDataIntervalResult: + """Energy data result for a specific interval.""" + + start_date_time: datetime + """Start date and time of this interval in UTC.""" + + energy: Optional[int] + """Energy in Watt Hours (Wh).""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/energy_usage_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/energy_usage_result.pyi new file mode 100644 index 0000000..8c6d37d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/energy_usage_result.pyi @@ -0,0 +1,24 @@ +from datetime import datetime + +class EnergyUsageResult: + """Contains local time, current power and the energy usage and runtime for today and for the current month.""" + + local_time: datetime + """Local time of the device.""" + current_power: int + """Current power in Milliwatts (mW).""" + today_runtime: int + """Today runtime in minutes.""" + today_energy: int + """Today energy usage in Watt Hours (Wh).""" + month_runtime: int + """Current month runtime in minutes.""" + month_energy: int + """Current month energy usage in Watt Hours (Wh).""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/power_data_result.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/power_data_result.pyi new file mode 100644 index 0000000..5840383 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/responses/power_data_result.pyi @@ -0,0 +1,40 @@ +from datetime import datetime +from typing import List, Optional + +class PowerDataResult: + """Power data result for the requested `PowerDataInterval`.""" + + start_date_time: datetime + """Start date and time of this result in UTC.""" + + end_date_time: datetime + """End date and time of this result in UTC.""" + + entries: List[PowerDataIntervalResult] + """List of power data entries.""" + + interval_length: int + """Interval length in minutes.""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class PowerDataIntervalResult: + """Power data result for a specific interval.""" + + start_date_time: datetime + """Start date and time of this interval in UTC.""" + + power: Optional[int] + """Power in Watts (W). `None` if no data is available for this interval.""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/rgb_light_strip_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/rgb_light_strip_handler.pyi new file mode 100644 index 0000000..1ae02bd --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/rgb_light_strip_handler.pyi @@ -0,0 +1,85 @@ +from tapo.device_management_ext import DeviceManagementExt +from tapo.requests import Color, ColorLightSetDeviceInfoParams +from tapo.responses import DeviceInfoRgbLightStripResult, DeviceUsageResult + +class RgbLightStripHandler(DeviceManagementExt): + """Handler for the [L900](https://www.tapo.com/en/search/?q=L900) devices.""" + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def refresh_session(self) -> None: + """Refreshes the authentication session.""" + + async def on(self) -> None: + """Turns *on* the device.""" + + async def off(self) -> None: + """Turns *off* the device.""" + + async def get_device_info(self) -> DeviceInfoRgbLightStripResult: + """Returns *device info* as `DeviceInfoRgbLightStripResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `RgbLightStripHandler.get_device_info_json`. + + Returns: + DeviceInfoRgbLightStripResult: Device info of Tapo L900. + Superset of `GenericDeviceInfoResult`. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_device_usage(self) -> DeviceUsageResult: + """Returns *device usage* as `DeviceUsageResult`. + + Returns: + DeviceUsageResult: Contains the time usage. + """ + + def set(self) -> ColorLightSetDeviceInfoParams: + """Returns a `ColorLightSetDeviceInfoParams` builder that allows + multiple properties to be set in a single request. + `ColorLightSetDeviceInfoParams.send` must be called at the end to apply the changes. + + Returns: + ColorLightSetDeviceInfoParams: Builder that is used by the + `RgbLightStripHandler.set` API to set multiple properties in a single request. + """ + + async def set_brightness(self, brightness: int) -> None: + """Sets the *brightness* and turns *on* the device. + + Args: + brightness (int): between 1 and 100 + """ + + async def set_color(self, color: Color) -> None: + """Sets the *color* and turns *on* the device. + + Args: + color (Color): one of `tapo.Color` as defined in the Google Home app. + """ + + async def set_hue_saturation(self, hue: int, saturation: int) -> None: + """Sets the *hue*, *saturation* and turns *on* the device. + + Args: + hue (int): between 0 and 360 + saturation (int): between 1 and 100 + """ + + async def set_color_temperature(self, color_temperature: int) -> None: + """Sets the *color temperature* and turns *on* the device. + + Args: + color_temperature (int): between 2500 and 6500 + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/rgbic_light_strip_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/rgbic_light_strip_handler.pyi new file mode 100644 index 0000000..e28e1a6 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/rgbic_light_strip_handler.pyi @@ -0,0 +1,98 @@ +from typing import Union + +from tapo.device_management_ext import DeviceManagementExt +from tapo.requests import Color, ColorLightSetDeviceInfoParams, LightingEffect, LightingEffectPreset +from tapo.responses import DeviceInfoRgbicLightStripResult, DeviceUsageResult + +class RgbicLightStripHandler(DeviceManagementExt): + """Handler for the [L920](https://www.tapo.com/en/search/?q=L920) and + [L930](https://www.tapo.com/en/search/?q=L930) devices. + """ + + def __init__(self, handler: object): + """Private constructor. + It should not be called from outside the tapo library. + """ + + async def refresh_session(self) -> None: + """Refreshes the authentication session.""" + + async def on(self) -> None: + """Turns *on* the device.""" + + async def off(self) -> None: + """Turns *off* the device.""" + + async def get_device_info(self) -> DeviceInfoRgbicLightStripResult: + """Returns *device info* as `DeviceInfoRgbicLightStripResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `RgbicLightStripHandler.get_device_info_json`. + + Returns: + DeviceInfoRgbicLightStripResult: Device info of Tapo L920 and L930. + Superset of `GenericDeviceInfoResult`. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_device_usage(self) -> DeviceUsageResult: + """Returns *device usage* as `DeviceUsageResult`. + + Returns: + DeviceUsageResult: Contains the time usage. + """ + + def set(self) -> ColorLightSetDeviceInfoParams: + """Returns a `ColorLightSetDeviceInfoParams` builder that allows + multiple properties to be set in a single request. + `ColorLightSetDeviceInfoParams.send` must be called at the end to apply the changes. + + Returns: + ColorLightSetDeviceInfoParams: Builder that is used by the + `RgbicLightStripHandler.set` API to set multiple properties in a single request. + """ + + async def set_brightness(self, brightness: int) -> None: + """Sets the *brightness* and turns *on* the device. + + Args: + brightness (int): between 1 and 100 + """ + + async def set_color(self, color: Color) -> None: + """Sets the *color* and turns *on* the device. + + Args: + color (Color): one of `tapo.Color` as defined in the Google Home app. + """ + + async def set_hue_saturation(self, hue: int, saturation: int) -> None: + """Sets the *hue*, *saturation* and turns *on* the device. + + Args: + hue (int): between 0 and 360 + saturation (int): between 1 and 100 + """ + + async def set_color_temperature(self, color_temperature: int) -> None: + """Sets the *color temperature* and turns *on* the device. + + Args: + color_temperature (int): between 2500 and 6500 + """ + + async def set_lighting_effect( + self, lighting_effect: Union[LightingEffect, LightingEffectPreset] + ) -> None: + """Sets a *lighting effect* and turns *on* the device. + + Args: + lighting_effect (LightingEffect | LightingEffectPreset) + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/s200b_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/s200b_handler.pyi new file mode 100644 index 0000000..feb2349 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/s200b_handler.pyi @@ -0,0 +1,81 @@ +from typing import List, Literal, Optional +from tapo.responses import S200BResult + +class S200BHandler: + """Handler for the [S200B](https://www.tapo.com/en/search/?q=S200B) devices.""" + + async def get_device_info(self) -> S200BResult: + """Returns *device info* as `S200BResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `S200BHandler.get_device_info_json`. + + Returns: + S200BResult: Device info of Tapo S200B button switch. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_trigger_logs(self, page_size: int, start_id: int) -> TriggerLogsS200BResult: + """Returns a list of *trigger logs*. + + Args: + page_size (int): the maximum number of log items to return + start_id (int): the log item `id` from which to start returning results + in reverse chronological order (newest first) + + Use a `start_id` of `0` to get the most recent X logs, where X is capped by `page_size`. + + Returns: + TriggerLogsS200BResult: Trigger logs result. + """ + +class TriggerLogsS200BResult: + """Trigger logs result.""" + + start_id: int + """The `id` of the most recent log item that is returned.""" + sum: int + """The total number of log items that the hub holds for this device.""" + logs: List[S200BLog] + """Log items in reverse chronological order (newest first).""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class S200BLog: + """S200B Log.""" + + event: Literal["rotation", "singleClick", "doubleClick", "lowBattery"] + id: int + timestamp: int + params: Optional[S200BRotationParams] + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class S200BRotationParams: + """S200B Rotation log params.""" + + rotation_degrees: int + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t100_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t100_handler.pyi new file mode 100644 index 0000000..1359ff2 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t100_handler.pyi @@ -0,0 +1,68 @@ +from typing import List, Literal +from tapo.responses import T100Result + +class T100Handler: + """Handler for the [T100](https://www.tapo.com/en/search/?q=T100) devices.""" + + async def get_device_info(self) -> T100Result: + """Returns *device info* as `T100Result`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `T100Handler.get_device_info_json`. + + Returns: + T100Result: Device info of Tapo T100 motion sensor. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_trigger_logs(self, page_size: int, start_id: int) -> TriggerLogsT100Result: + """Returns a list of *trigger logs*. + + Args: + page_size (int): the maximum number of log items to return + start_id (int): the log item `id` from which to start returning results + in reverse chronological order (newest first) + + Use a `start_id` of `0` to get the most recent X logs, where X is capped by `page_size`. + + Returns: + TriggerLogsT100Result: Trigger logs result. + """ + +class TriggerLogsT100Result: + """Trigger logs result.""" + + start_id: int + """The `id` of the most recent log item that is returned.""" + sum: int + """The total number of log items that the hub holds for this device.""" + logs: List[T100Log] + """Log items in reverse chronological order (newest first).""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class T100Log: + """T110 Log.""" + + event: Literal["motion"] + id: int + timestamp: int + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t110_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t110_handler.pyi new file mode 100644 index 0000000..edb9187 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t110_handler.pyi @@ -0,0 +1,68 @@ +from typing import List, Literal +from tapo.responses import T110Result + +class T110Handler: + """Handler for the [T110](https://www.tapo.com/en/search/?q=T110) devices.""" + + async def get_device_info(self) -> T110Result: + """Returns *device info* as `T110Result`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `T110Handler.get_device_info_json`. + + Returns: + T110Result: Device info of Tapo T110 contact sensor. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_trigger_logs(self, page_size: int, start_id: int) -> TriggerLogsT110Result: + """Returns a list of *trigger logs*. + + Args: + page_size (int): the maximum number of log items to return + start_id (int): the log item `id` from which to start returning results + in reverse chronological order (newest first) + + Use a `start_id` of `0` to get the most recent X logs, where X is capped by `page_size`. + + Returns: + TriggerLogsT110Result: Trigger logs result. + """ + +class TriggerLogsT110Result: + """Trigger logs result.""" + + start_id: int + """The `id` of the most recent log item that is returned.""" + sum: int + """The total number of log items that the hub holds for this device.""" + logs: List[T110Log] + """Log items in reverse chronological order (newest first).""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class T110Log: + """T110 Log.""" + + event: Literal["close", "open", "keepOpen"] + id: int + timestamp: int + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t300_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t300_handler.pyi new file mode 100644 index 0000000..c5eb0c0 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t300_handler.pyi @@ -0,0 +1,68 @@ +from typing import List, Literal +from tapo.responses import T300Result + +class T300Handler: + """Handler for the [T300](https://www.tapo.com/en/search/?q=T300) devices.""" + + async def get_device_info(self) -> T300Result: + """Returns *device info* as `T300Result`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `T300Handler.get_device_info_json`. + + Returns: + T300Result: Device info of Tapo T300 water sensor. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_trigger_logs(self, page_size: int, start_id: int) -> TriggerLogsT300Result: + """Returns a list of *trigger logs*. + + Args: + page_size (int): the maximum number of log items to return + start_id (int): the log item `id` from which to start returning results + in reverse chronological order (newest first) + + Use a `start_id` of `0` to get the most recent X logs, where X is capped by `page_size`. + + Returns: + TriggerLogsT300Result: Trigger logs result. + """ + +class TriggerLogsT300Result: + """Trigger logs result.""" + + start_id: int + """The `id` of the most recent log item that is returned.""" + sum: int + """The total number of log items that the hub holds for this device.""" + logs: List[T300Log] + """Log items in reverse chronological order (newest first).""" + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ + +class T300Log: + """T300 Log.""" + + event: Literal["waterDry", "waterLeak"] + id: int + timestamp: int + + def to_dict(self) -> dict: + """Gets all the properties of this result as a dictionary. + + Returns: + dict: The result as a dictionary. + """ diff --git a/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t31x_handler.pyi b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t31x_handler.pyi new file mode 100644 index 0000000..d937a72 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo-py/tapo-py/tapo/t31x_handler.pyi @@ -0,0 +1,32 @@ +from tapo.responses import T31XResult, TemperatureHumidityRecords + +class T31XHandler: + """Handler for the [T310](https://www.tapo.com/en/search/?q=T310) + and [T315](https://www.tapo.com/en/search/?q=T315) devices.""" + + async def get_device_info(self) -> T31XResult: + """Returns *device info* as `T31XResult`. + It is not guaranteed to contain all the properties returned from the Tapo API. + If the deserialization fails, or if a property that you care about it's not present, + try `T31XHandler.get_device_info_json`. + + Returns: + T31XResult: Device info of Tapo T310 and T315 temperature and humidity sensors. + """ + + async def get_device_info_json(self) -> dict: + """Returns *device info* as json. + It contains all the properties returned from the Tapo API. + + Returns: + dict: Device info as a dictionary. + """ + + async def get_temperature_humidity_records(self) -> TemperatureHumidityRecords: + """Returns *temperature and humidity records* from the last 24 hours + at 15 minute intervals as `TemperatureHumidityRecords`. + + Returns: + TemperatureHumidityRecords: Temperature and Humidity records + for the last 24 hours at 15 minute intervals. + """ diff --git a/agents/tapo/tapo-fork/tapo/CHANGELOG.md b/agents/tapo/tapo-fork/tapo/CHANGELOG.md new file mode 120000 index 0000000..04c99a5 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/CHANGELOG.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/agents/tapo/tapo-fork/tapo/Cargo.toml b/agents/tapo/tapo-fork/tapo/Cargo.toml new file mode 100644 index 0000000..38242b9 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "tapo" +version = "0.8.8" +edition = "2024" +rust-version = "1.88" +license = "MIT" +authors = ["Mihai Dinculescu "] +description = "Unofficial Tapo API Client. Works with TP-Link Tapo smart devices. Tested with light bulbs (L510, L520, L530, L535, L610, L630), light strips (L900, L920, L930), plugs (P100, P105, P110, P110M, P115), power strips (P300, P304M, P306, P316M), hubs (H100), switches (S200B) and sensors (KE100, T100, T110, T300, T310, T315)." +keywords = ["IOT", "tapo", "smart-home", "smart-bulb", "smart-plug"] +categories = ["hardware-support", "embedded", "development-tools"] +readme = "README.md" +repository = "https://github.com/mihai-dinculescu/tapo" + +[features] +default = [] +python = ["dep:pyo3"] + +[dependencies] +anyhow = { workspace = true } +async-trait = "0.1" +chrono = { workspace = true, features = ["clock", "serde"] } +crc32fast = "1.5" +itertools = "0.14" +lazy_static = "1.5" +log = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["cookies", "json"] } +serde = { workspace = true, features = ["derive", "serde_derive"] } +serde_json = { workspace = true } +serde_with = "3.16" +thiserror = "2.0" +tokio = { workspace = true, features = ["sync"] } +tokio-stream = "0.1" +uuid = { version = "1.18", features = ["serde", "v4"] } + +# security +aes = "0.8" +base16ct = { version = "0.3", features = ["alloc"] } +base64 = "0.22" +cbc = { version = "0.1", features = ["alloc"] } +rsa = { version = "0.9", features = ["getrandom"] } +sha1 = "0.10" +sha2 = "0.10" + +# FFI +pyo3 = { workspace = true, features = ["serde", "chrono"], optional = true } + +[dev-dependencies] +once_cell = "1.21" +pretty_env_logger = "0.5" +rand = "0.8" +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/agents/tapo/tapo-fork/tapo/LICENSE b/agents/tapo/tapo-fork/tapo/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/agents/tapo/tapo-fork/tapo/README.md b/agents/tapo/tapo-fork/tapo/README.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/agents/tapo/tapo-fork/tapo/examples/common.rs b/agents/tapo/tapo-fork/tapo/examples/common.rs new file mode 100644 index 0000000..04f885a --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/common.rs @@ -0,0 +1,21 @@ +/// Common utilities for examples. +use std::env; + +use log::LevelFilter; + +pub fn setup_logger() { + let log_level = env::var("RUST_LOG") + .unwrap_or_else(|_| "info".to_string()) + .parse() + .unwrap_or(LevelFilter::Info); + + pretty_env_logger::formatted_timed_builder() + .filter(Some("tapo"), log_level) + .init(); +} + +#[allow(dead_code)] +fn main() { + println!("This is not a real example."); + println!("This entry point has been included solely to prevent a warning about its absence."); +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_discover_devices.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_discover_devices.rs new file mode 100644 index 0000000..d1713c6 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_discover_devices.rs @@ -0,0 +1,131 @@ +/// Discover devices on the local network Example +use std::env; + +use log::{error, info, warn}; +use tapo::ApiClient; +use tapo::{DiscoveryResult, StreamExt}; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let target = env::var("TAPO_DISCOVERY_TARGET").unwrap_or_else(|_| "192.168.1.255".to_string()); + let timeout_s = env::var("TAPO_DISCOVERY_TIMEOUT") + .unwrap_or_else(|_| "10".to_string()) + .parse::() + .unwrap_or(10); + + info!("Discovering Tapo devices on target: {target} for {timeout_s} seconds..."); + + let api_client = ApiClient::new(tapo_username, tapo_password); + let mut discovery = api_client.discover_devices(target, timeout_s).await?; + + while let Some(discovery_result) = discovery.next().await { + if let Ok(device) = discovery_result { + match device { + DiscoveryResult::GenericDevice { + device_info, + handler: _, + } => { + // If you believe this device is already supported, or would like to explore adding support for a currently + // unsupported model, please [open an issue on GitHub](https://github.com/mihai-dinculescu/tapo/issues) + // to start the discussion. + warn!( + "Found Unsupported Device {:?} of model {:?} at IP address {:?}.", + device_info.nickname, device_info.model, device_info.ip + ); + } + DiscoveryResult::Light { + device_info, + handler: _, + } => { + info!( + "Found {:?} of model {:?} at IP address {:?}.", + device_info.nickname, device_info.model, device_info.ip + ); + } + DiscoveryResult::ColorLight { + device_info, + handler: _, + } => { + info!( + "Found {:?} of model {:?} at IP address {:?}.", + device_info.nickname, device_info.model, device_info.ip + ); + } + DiscoveryResult::RgbLightStrip { + device_info, + handler: _, + } => { + info!( + "Found {:?} of model {:?} at IP address {:?}.", + device_info.nickname, device_info.model, device_info.ip + ); + } + DiscoveryResult::RgbicLightStrip { + device_info, + handler: _, + } => { + info!( + "Found {:?} of model {:?} at IP address {:?}.", + device_info.nickname, device_info.model, device_info.ip + ); + } + DiscoveryResult::Plug { + device_info, + handler: _, + } => { + info!( + "Found {:?} of model {:?} at IP address {:?}.", + device_info.nickname, device_info.model, device_info.ip + ); + } + DiscoveryResult::PlugEnergyMonitoring { + device_info, + handler: _, + } => { + info!( + "Found {:?} of model {:?} at IP address {:?}.", + device_info.nickname, device_info.model, device_info.ip + ); + } + DiscoveryResult::PowerStrip { + device_info, + handler: _, + } => { + info!( + "Found Power Strip of model {:?} at IP address {:?}.", + device_info.model, device_info.ip + ); + } + DiscoveryResult::PowerStripEnergyMonitoring { + device_info, + handler: _, + } => { + info!( + "Found Power Strip with Energy Monitoring of model {:?} at IP address {:?}.", + device_info.model, device_info.ip + ); + } + DiscoveryResult::Hub { + device_info, + handler: _, + } => { + info!( + "Found {:?} of model {:?} at IP address {:?}.", + device_info.nickname, device_info.model, device_info.ip + ); + } + } + } else if let Err(e) = discovery_result { + error!("Error discovering device: {e:?}"); + continue; + } + } + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_generic_device.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_generic_device.rs new file mode 100644 index 0000000..f65b8c1 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_generic_device.rs @@ -0,0 +1,34 @@ +/// Generic Device Example +use std::{env, thread, time::Duration}; + +use log::info; +use tapo::ApiClient; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let device = ApiClient::new(tapo_username, tapo_password) + .generic_device(ip_address) + .await?; + + info!("Turning device on..."); + device.on().await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Turning device off..."); + device.off().await?; + + let device_info = device.get_device_info().await?; + info!("Device info: {device_info:?}"); + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_generic_device_toggle.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_generic_device_toggle.rs new file mode 100644 index 0000000..0e84f57 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_generic_device_toggle.rs @@ -0,0 +1,38 @@ +/// Toggle Generic Device Example +use std::env; + +use log::{info, warn}; +use tapo::ApiClient; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let device = ApiClient::new(tapo_username, tapo_password) + .generic_device(ip_address) + .await?; + + let device_info = device.get_device_info().await?; + + match device_info.device_on { + Some(true) => { + info!("Device is on. Turning it off..."); + device.off().await?; + } + Some(false) => { + info!("Device is off. Turning it on..."); + device.on().await?; + } + None => { + warn!("This device does not support on/off functionality."); + } + } + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_h100.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_h100.rs new file mode 100644 index 0000000..20e11b8 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_h100.rs @@ -0,0 +1,132 @@ +//! H100 Example +use std::env; +use std::time::Duration; + +use log::info; +use tapo::requests::{AlarmDuration, AlarmRingtone, AlarmVolume}; +use tapo::responses::ChildDeviceHubResult; +use tapo::{ApiClient, HubDevice}; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let hub = ApiClient::new(tapo_username, tapo_password) + .h100(ip_address) + .await?; + + let device_info = hub.get_device_info().await?; + info!("Device info: {device_info:?}"); + + info!("Getting child devices..."); + let child_device_list = hub.get_child_device_list().await?; + + for child in child_device_list { + match child { + ChildDeviceHubResult::KE100(device) => { + info!( + "Found KE100 child device with nickname: {}, id: {}, current temperature: {} {:?} and target temperature: {} {:?}.", + device.nickname, + device.device_id, + device.current_temperature, + device.temperature_unit, + device.target_temperature, + device.temperature_unit, + ); + } + ChildDeviceHubResult::S200B(device) => { + let s200b = hub + .s200b(HubDevice::ByDeviceId(device.device_id.clone())) + .await?; + let trigger_logs = s200b.get_trigger_logs(5, 0).await?; + + info!( + "Found S200B child device with nickname: {}, id: {}, last 5 trigger logs: {:?}.", + device.nickname, device.device_id, trigger_logs + ); + } + ChildDeviceHubResult::T100(device) => { + let t100 = hub + .t100(HubDevice::ByDeviceId(device.device_id.clone())) + .await?; + let trigger_logs = t100.get_trigger_logs(5, 0).await?; + + info!( + "Found T100 child device with nickname: {}, id: {}, detected: {}, last 5 trigger logs: {:?}.", + device.nickname, device.device_id, device.detected, trigger_logs + ); + } + ChildDeviceHubResult::T110(device) => { + let t110 = hub + .t110(HubDevice::ByDeviceId(device.device_id.clone())) + .await?; + let trigger_logs = t110.get_trigger_logs(5, 0).await?; + + info!( + "Found T110 child device with nickname: {}, id: {}, open: {}, last 5 trigger logs: {:?}.", + device.nickname, device.device_id, device.open, trigger_logs + ); + } + ChildDeviceHubResult::T300(device) => { + let t300 = hub + .t300(HubDevice::ByDeviceId(device.device_id.clone())) + .await?; + let trigger_logs = t300.get_trigger_logs(5, 0).await?; + + info!( + "Found T300 child device with nickname: {}, id: {}, in_alarm: {}, water_leak_status: {:?}, last 5 trigger logs: {:?}.", + device.nickname, + device.device_id, + device.in_alarm, + device.water_leak_status, + trigger_logs + ); + } + ChildDeviceHubResult::T310(device) | ChildDeviceHubResult::T315(device) => { + let t31x = hub + .t315(HubDevice::ByDeviceId(device.device_id.clone())) + .await?; + let temperature_humidity_records = t31x.get_temperature_humidity_records().await?; + + info!( + "Found T31X child device with nickname: {}, id: {}, temperature: {} {:?}, humidity: {}%, earliest temperature and humidity record available: {:?}.", + device.nickname, + device.device_id, + device.current_temperature, + device.temperature_unit, + device.current_humidity, + temperature_humidity_records.records.first() + ); + } + _ => { + info!("Found unsupported device.") + } + } + } + + info!("Triggering the alarm ringtone 'Alarm 1' at a 'Low' volume for '3 Seconds'..."); + hub.play_alarm( + AlarmRingtone::Alarm1, + AlarmVolume::Low, + AlarmDuration::Seconds(3), + ) + .await?; + + let device_info = hub.get_device_info().await?; + info!("Is device ringing?: {:?}", device_info.in_alarm); + + info!("Stopping the alarm after 1 Second..."); + tokio::time::sleep(Duration::from_secs(1)).await; + hub.stop_alarm().await?; + + let device_info = hub.get_device_info().await?; + info!("Is device ringing?: {:?}", device_info.in_alarm); + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_ke100.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_ke100.rs new file mode 100644 index 0000000..7679c5e --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_ke100.rs @@ -0,0 +1,45 @@ +/// KE100 TRV Example +use std::env; + +use log::info; +use tapo::requests::TemperatureUnitKE100; +use tapo::{ApiClient, HubDevice}; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + // Name of the KE100 device. + // Can be obtained from the Tapo App or by executing `get_child_device_component_list()` on the hub device. + let device_name = env::var("DEVICE_NAME")?; + let target_temperature: u8 = env::var("TARGET_TEMPERATURE")?.parse()?; + + let hub = ApiClient::new(tapo_username, tapo_password) + .h100(ip_address) + .await?; + + // Get a handler for the child device + let device = hub.ke100(HubDevice::ByNickname(device_name)).await?; + + // Get the device info of the child device + let device_info = device.get_device_info().await?; + info!("Device info: {device_info:?}"); + + // Set target temperature. + // KE100 currently only supports Celsius as temperature unit. + info!("Setting target temperature to {target_temperature} degrees Celsius..."); + device + .set_target_temperature(target_temperature, TemperatureUnitKE100::Celsius) + .await?; + + // Get the device info of the child device + let device_info = device.get_device_info().await?; + info!("Device info: {device_info:?}"); + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_l510.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_l510.rs new file mode 100644 index 0000000..77ae025 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_l510.rs @@ -0,0 +1,46 @@ +/// L510, L520 and L610 Example +use std::{env, thread, time::Duration}; + +use log::info; +use tapo::ApiClient; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let device = ApiClient::new(tapo_username, tapo_password) + .l510(ip_address) + .await?; + + info!("Turning device on..."); + device.on().await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Setting the brightness to 30%..."); + device.set_brightness(30).await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Turning device off..."); + device.off().await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + let device_info = device.get_device_info().await?; + info!("Device info: {device_info:?}"); + + let device_usage = device.get_device_usage().await?; + info!("Device usage: {device_usage:?}"); + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_l530.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_l530.rs new file mode 100644 index 0000000..06889ec --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_l530.rs @@ -0,0 +1,66 @@ +/// L530, L535 and L630 Example +use std::{env, thread, time::Duration}; + +use log::info; +use tapo::{ApiClient, requests::Color}; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let device = ApiClient::new(tapo_username, tapo_password) + .l530(ip_address) + .await?; + + info!("Turning device on..."); + device.on().await?; + + info!("Setting the brightness to 30%..."); + device.set_brightness(30).await?; + + info!("Setting the color to `Chocolate`..."); + device.set_color(Color::Chocolate).await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Setting the color to `Deep Sky Blue` using the `hue` and `saturation`..."); + device.set_hue_saturation(195, 100).await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Setting the color to `Incandescent` using the `color temperature`..."); + device.set_color_temperature(2700).await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Using the `set` API to set multiple properties in a single request..."); + device + .set() + .brightness(50) + .color(Color::HotPink) + .send(&device) + .await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Turning device off..."); + device.off().await?; + + let device_info = device.get_device_info().await?; + info!("Device info: {device_info:?}"); + + let device_usage = device.get_device_usage().await?; + info!("Device usage: {device_usage:?}"); + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_l900.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_l900.rs new file mode 100644 index 0000000..b14b349 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_l900.rs @@ -0,0 +1,66 @@ +/// L900 Example +use std::{env, thread, time::Duration}; + +use log::info; +use tapo::{ApiClient, requests::Color}; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let device = ApiClient::new(tapo_username, tapo_password) + .l900(ip_address) + .await?; + + info!("Turning device on..."); + device.on().await?; + + info!("Setting the brightness to 30%..."); + device.set_brightness(30).await?; + + info!("Setting the color to `Chocolate`..."); + device.set_color(Color::Chocolate).await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Setting the color to `Deep Sky Blue` using the `hue` and `saturation`..."); + device.set_hue_saturation(195, 100).await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Setting the color to `Incandescent` using the `color temperature`..."); + device.set_color_temperature(2700).await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Using the `set` API to set multiple properties in a single request..."); + device + .set() + .brightness(50) + .color(Color::HotPink) + .send(&device) + .await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Turning device off..."); + device.off().await?; + + let device_info = device.get_device_info().await?; + info!("Device info: {device_info:?}"); + + let device_usage = device.get_device_usage().await?; + info!("Device usage: {device_usage:?}"); + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_l930.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_l930.rs new file mode 100644 index 0000000..d88070b --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_l930.rs @@ -0,0 +1,113 @@ +/// L920 and L930 Example +use std::{env, thread, time::Duration}; + +use log::info; +use tapo::ApiClient; +use tapo::requests::{Color, LightingEffect, LightingEffectPreset, LightingEffectType}; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let device = ApiClient::new(tapo_username, tapo_password) + .l930(ip_address) + .await?; + + info!("Turning device on..."); + device.on().await?; + + info!("Setting the brightness to 30%..."); + device.set_brightness(30).await?; + + info!("Setting the color to `Chocolate`..."); + device.set_color(Color::Chocolate).await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Setting the color to `Deep Sky Blue` using the `hue` and `saturation`..."); + device.set_hue_saturation(195, 100).await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Setting the color to `Incandescent` using the `color temperature`..."); + device.set_color_temperature(2700).await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Using the `set` API to set multiple properties in a single request..."); + device + .set() + .brightness(50) + .color(Color::HotPink) + .send(&device) + .await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Setting a preset Lighting effect..."); + device + .set_lighting_effect(LightingEffectPreset::BubblingCauldron) + .await?; + + info!("Waiting 10 seconds..."); + thread::sleep(Duration::from_secs(10)); + + info!("Setting a custom static Lighting effect..."); + let custom_effect = LightingEffect::new( + "My Custom Static Effect", + LightingEffectType::Static, + true, + true, + 100, + vec![[359, 85, 100]], + ) + .with_expansion_strategy(1) + .with_segments(vec![0, 1, 2]) + .with_sequence(vec![[359, 85, 100], [0, 0, 100], [236, 72, 100]]); + + device.set_lighting_effect(custom_effect).await?; + + info!("Waiting 10 seconds..."); + thread::sleep(Duration::from_secs(10)); + + info!("Setting a custom sequence Lighting effect..."); + let custom_effect = LightingEffect::new( + "My Custom Sequence Effect", + LightingEffectType::Sequence, + true, + true, + 100, + vec![[359, 85, 100]], + ) + .with_expansion_strategy(1) + .with_segments(vec![0, 1, 2]) + .with_sequence(vec![[359, 85, 100], [0, 0, 100], [236, 72, 100]]) + .with_direction(1) + .with_duration(50); + + device.set_lighting_effect(custom_effect).await?; + + info!("Waiting 10 seconds..."); + thread::sleep(Duration::from_secs(10)); + + info!("Turning device off..."); + device.off().await?; + + let device_info = device.get_device_info().await?; + info!("Device info: {device_info:?}"); + + let device_usage = device.get_device_usage().await?; + info!("Device usage: {device_usage:?}"); + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_p100.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_p100.rs new file mode 100644 index 0000000..89cdf7e --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_p100.rs @@ -0,0 +1,37 @@ +/// P100 and P105 Example +use std::{env, thread, time::Duration}; + +use log::info; +use tapo::ApiClient; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let device = ApiClient::new(tapo_username, tapo_password) + .p100(ip_address) + .await?; + + info!("Turning device on..."); + device.on().await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Turning device off..."); + device.off().await?; + + let device_info = device.get_device_info().await?; + info!("Device info: {device_info:?}"); + + let device_usage = device.get_device_usage().await?; + info!("Device usage: {device_usage:?}"); + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_p110.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_p110.rs new file mode 100644 index 0000000..c59e4fe --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_p110.rs @@ -0,0 +1,135 @@ +/// P110, P110M and P115 Example +use std::{env, thread, time::Duration}; + +use chrono::{Datelike as _, NaiveDate, Utc}; +use log::info; +use tapo::ApiClient; +use tapo::requests::{EnergyDataInterval, PowerDataInterval}; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let device = ApiClient::new(tapo_username, tapo_password) + .p110(ip_address) + .await?; + + info!("Turning device on..."); + device.on().await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Turning device off..."); + device.off().await?; + + let device_info = device.get_device_info().await?; + info!("Device info: {device_info:?}"); + + let current_power = device.get_current_power().await?; + info!("Current power: {current_power:?}"); + + let device_usage = device.get_device_usage().await?; + info!("Device usage: {device_usage:?}"); + + let energy_usage = device.get_energy_usage().await?; + info!("Energy usage: {energy_usage:?}"); + + let current_date = Utc::now().naive_utc().date(); + + // Energy data - Hourly interval + // `start_date` and `end_date` are an inclusive interval that must not be greater than 8 days. + let energy_data_hourly = device + .get_energy_data(EnergyDataInterval::Hourly { + start_date: current_date, + end_date: current_date, + }) + .await?; + info!( + "Energy data (hourly): Start date time '{}', Entries {}, First entry: {:?}", + energy_data_hourly.start_date_time, + energy_data_hourly.entries.len(), + energy_data_hourly.entries.first() + ); + + // Energy data - Daily interval + // `start_date` must be the first day of a quarter. + let energy_data_daily = device + .get_energy_data(EnergyDataInterval::Daily { + start_date: NaiveDate::from_ymd_opt( + current_date.year(), + get_quarter_start_month(¤t_date), + 1, + ) + .unwrap(), + }) + .await?; + info!( + "Energy data (daily): Start date time '{}', Entries {}, First entry: {:?}", + energy_data_daily.start_date_time, + energy_data_daily.entries.len(), + energy_data_daily.entries.first() + ); + + // Energy data - Monthly interval + // `start_date` must be the first day of a year. + let energy_data_monthly = device + .get_energy_data(EnergyDataInterval::Monthly { + start_date: NaiveDate::from_ymd_opt(current_date.year(), 1, 1).unwrap(), + }) + .await?; + info!( + "Energy data (monthly): Start date time '{}', Entries {}, First entry: {:?}", + energy_data_monthly.start_date_time, + energy_data_monthly.entries.len(), + energy_data_monthly.entries.first() + ); + + // Power data - Every 5 minutes interval + // `start_date_time` and `end_date_time` describe an exclusive interval. + // If the result would yield more than 144 entries (i.e. 12 hours), + // the `end_date_time` will be adjusted to an earlier date and time. + let power_data_every_5_minutes = device + .get_power_data(PowerDataInterval::Every5Minutes { + start_date_time: Utc::now() - chrono::Duration::hours(12), + end_date_time: Utc::now(), + }) + .await?; + info!( + "Power data (every 5 minutes): Start date time '{}', End date time '{}', Entries {}, First entry: {:?}", + power_data_every_5_minutes.start_date_time, + power_data_every_5_minutes.end_date_time, + power_data_every_5_minutes.entries.len(), + power_data_every_5_minutes.entries.first() + ); + + // Power data - Hourly interval + // `start_date_time` and `end_date_time` describe an exclusive interval. + // If the result would yield more than 144 entries (i.e. 6 days), + // the `end_date_time` will be adjusted to an earlier date and time. + let power_data_hourly = device + .get_power_data(PowerDataInterval::Hourly { + start_date_time: Utc::now() - chrono::Duration::days(3), + end_date_time: Utc::now(), + }) + .await?; + info!( + "Power data (hourly): Start date time '{}', End date time '{}', Entries {}, First entry: {:?}", + power_data_hourly.start_date_time, + power_data_hourly.end_date_time, + power_data_hourly.entries.len(), + power_data_hourly.entries.first() + ); + + Ok(()) +} + +fn get_quarter_start_month(current_date: &NaiveDate) -> u32 { + ((current_date.month() - 1) / 3) * 3 + 1 +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_p300.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_p300.rs new file mode 100644 index 0000000..a879f94 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_p300.rs @@ -0,0 +1,49 @@ +/// P300 and P306 Example +use std::{env, thread, time::Duration}; + +use log::info; +use tapo::{ApiClient, Plug}; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let power_strip = ApiClient::new(tapo_username, tapo_password) + .p300(ip_address) + .await?; + + let device_info = power_strip.get_device_info().await?; + info!("Device info: {device_info:?}"); + + info!("Getting child devices..."); + let child_device_list = power_strip.get_child_device_list().await?; + info!("Found {} plugs", child_device_list.len()); + + for (index, child) in child_device_list.into_iter().enumerate() { + info!("=== ({}) {} ===", index + 1, child.nickname); + info!("Device ID: {}", child.device_id); + info!("State: {}", child.device_on); + + let plug = power_strip.plug(Plug::ByDeviceId(child.device_id)).await?; + + info!("Turning device on..."); + plug.on().await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Turning device off..."); + plug.off().await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + } + + Ok(()) +} diff --git a/agents/tapo/tapo-fork/tapo/examples/tapo_p304.rs b/agents/tapo/tapo-fork/tapo/examples/tapo_p304.rs new file mode 100644 index 0000000..df37c45 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/examples/tapo_p304.rs @@ -0,0 +1,152 @@ +/// P304M and P316M Example +use std::{env, thread, time::Duration}; + +use chrono::{Datelike as _, NaiveDate, Utc}; +use log::info; +use tapo::{ + ApiClient, Plug, + requests::{EnergyDataInterval, PowerDataInterval}, +}; + +mod common; + +#[tokio::main] +async fn main() -> Result<(), Box> { + common::setup_logger(); + + let tapo_username = env::var("TAPO_USERNAME")?; + let tapo_password = env::var("TAPO_PASSWORD")?; + let ip_address = env::var("IP_ADDRESS")?; + + let power_strip = ApiClient::new(tapo_username, tapo_password) + .p304(ip_address) + .await?; + + let device_info = power_strip.get_device_info().await?; + info!("Device info: {device_info:?}"); + + info!("Getting child devices..."); + let child_device_list = power_strip.get_child_device_list().await?; + info!("Found {} plugs", child_device_list.len()); + + for (index, child) in child_device_list.into_iter().enumerate() { + info!("=== ({}) {} ===", index + 1, child.nickname); + info!("Device ID: {}", child.device_id); + info!("State: {}", child.device_on); + + let plug = power_strip.plug(Plug::ByDeviceId(child.device_id)).await?; + + info!("Turning device on..."); + plug.on().await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + info!("Turning device off..."); + plug.off().await?; + + info!("Waiting 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + + let current_power = plug.get_current_power().await?; + info!("Current power: {current_power:?}"); + + let device_usage = plug.get_device_usage().await?; + info!("Device usage: {device_usage:?}"); + + let energy_usage = plug.get_energy_usage().await?; + info!("Energy usage: {energy_usage:?}"); + + let current_date = Utc::now().naive_utc().date(); + + // Energy data - Hourly interval + // `start_date` and `end_date` are an inclusive interval that must not be greater than 8 days. + let energy_data_hourly = plug + .get_energy_data(EnergyDataInterval::Hourly { + start_date: current_date, + end_date: current_date, + }) + .await?; + info!( + "Energy data (hourly): Start date time '{}', Entries {}, First entry: {:?}", + energy_data_hourly.start_date_time, + energy_data_hourly.entries.len(), + energy_data_hourly.entries.first() + ); + + // Energy data - Daily interval + // `start_date` must be the first day of a quarter. + let energy_data_daily = plug + .get_energy_data(EnergyDataInterval::Daily { + start_date: NaiveDate::from_ymd_opt( + current_date.year(), + get_quarter_start_month(¤t_date), + 1, + ) + .unwrap(), + }) + .await?; + info!( + "Energy data (daily): Start date time '{}', Entries {}, First entry: {:?}", + energy_data_daily.start_date_time, + energy_data_daily.entries.len(), + energy_data_daily.entries.first() + ); + + // Energy data - Monthly interval + // `start_date` must be the first day of a year. + let energy_data_monthly = plug + .get_energy_data(EnergyDataInterval::Monthly { + start_date: NaiveDate::from_ymd_opt(current_date.year(), 1, 1).unwrap(), + }) + .await?; + info!( + "Energy data (monthly): Start date time '{}', Entries {}, First entry: {:?}", + energy_data_monthly.start_date_time, + energy_data_monthly.entries.len(), + energy_data_monthly.entries.first() + ); + + // Power data - Every 5 minutes interval + // `start_date_time` and `end_date_time` describe an exclusive interval. + // If the result would yield more than 144 entries (i.e. 12 hours), + // the `end_date_time` will be adjusted to an earlier date and time. + let power_data_every_5_minutes = plug + .get_power_data(PowerDataInterval::Every5Minutes { + start_date_time: Utc::now() - chrono::Duration::hours(12), + end_date_time: Utc::now(), + }) + .await?; + info!( + "Power data (every 5 minutes): Start date time '{}', End date time '{}', Entries {}, First entry: {:?}", + power_data_every_5_minutes.start_date_time, + power_data_every_5_minutes.end_date_time, + power_data_every_5_minutes.entries.len(), + power_data_every_5_minutes.entries.first() + ); + + // Power data - Hourly interval + // `start_date_time` and `end_date_time` describe an exclusive interval. + // If the result would yield more than 144 entries (i.e. 6 days), + // the `end_date_time` will be adjusted to an earlier date and time. + let power_data_hourly = plug + .get_power_data(PowerDataInterval::Hourly { + start_date_time: Utc::now() - chrono::Duration::days(3), + end_date_time: Utc::now(), + }) + .await?; + info!( + "Power data (hourly): Start date time '{}', End date time '{}', Entries {}, First entry: {:?}", + power_data_hourly.start_date_time, + power_data_hourly.end_date_time, + power_data_hourly.entries.len(), + power_data_hourly.entries.first() + ); + } + + Ok(()) +} + +fn get_quarter_start_month(current_date: &NaiveDate) -> u32 { + ((current_date.month() - 1) / 3) * 3 + 1 +} diff --git a/agents/tapo/tapo-fork/tapo/src/api.rs b/agents/tapo/tapo-fork/tapo/src/api.rs new file mode 100644 index 0000000..454611d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api.rs @@ -0,0 +1,34 @@ +mod api_client; +mod capabilities; +mod child_devices; +mod color_light_handler; +mod discovery; +mod generic_device_handler; +mod handler_ext; +mod hub_handler; +mod light_handler; +mod plug; +mod plug_energy_monitoring_handler; +mod plug_handler; +mod power_strip_energy_monitoring_handler; +mod power_strip_handler; +mod protocol; +mod rgb_light_strip_handler; +mod rgbic_light_strip_handler; + +pub use api_client::*; +pub use capabilities::*; +pub use child_devices::*; +pub use color_light_handler::*; +pub use discovery::*; +pub use generic_device_handler::*; +pub use handler_ext::*; +pub use hub_handler::*; +pub use light_handler::*; +pub use plug::*; +pub use plug_energy_monitoring_handler::*; +pub use plug_handler::*; +pub use power_strip_energy_monitoring_handler::*; +pub use power_strip_handler::*; +pub use rgb_light_strip_handler::*; +pub use rgbic_light_strip_handler::*; diff --git a/agents/tapo/tapo-fork/tapo/src/api/api_client.rs b/agents/tapo/tapo-fork/tapo/src/api/api_client.rs new file mode 100644 index 0000000..2ef0ecf --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/api_client.rs @@ -0,0 +1,958 @@ +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use log::debug; +use reqwest::Client; +use serde::de::DeserializeOwned; +use tokio::sync::RwLock; + +use crate::error::{Error, TapoResponseError}; +use crate::requests::{ + ControlChildParams, DeviceRebootParams, EmptyParams, EnergyDataInterval, + GetChildDeviceListParams, GetEnergyDataParams, GetPowerDataParams, GetRulesParams, + LightingEffect, MultipleRequestParams, PlayAlarmParams, PowerDataInterval, TapoParams, + TapoRequest, +}; +use crate::responses::{ + ControlChildResult, CountdownRulesResult, CurrentPowerResult, DecodableResultExt, + EnergyDataResult, EnergyDataResultRaw, EnergyUsageResult, NextEventResult, PowerDataResult, + PowerDataResultRaw, ScheduleRulesResult, SupportedAlarmTypeListResult, TapoMultipleResponse, + TapoResponseExt, TapoResult, validate_response, +}; + +use super::discovery::DeviceDiscovery; +use super::protocol::{TapoProtocol, TapoProtocolExt}; +use super::{ + ColorLightHandler, GenericDeviceHandler, HubHandler, LightHandler, PlugEnergyMonitoringHandler, + PlugHandler, PowerStripEnergyMonitoringHandler, PowerStripHandler, RgbLightStripHandler, + RgbicLightStripHandler, +}; + +const TERMINAL_UUID: &str = "00-00-00-00-00-00"; + +/// Implemented by all ApiClient implementations. +#[async_trait] +pub trait ApiClientExt: std::fmt::Debug + Send + Sync { + /// Sets device info by sending the given parameters. + async fn set_device_info(&self, device_info_params: serde_json::Value) -> Result<(), Error>; + /// Reboots the device. + async fn device_reboot(&self, delay_s: u16) -> Result<(), Error>; + /// Hardware resets the device. + async fn device_reset(&self) -> Result<(), Error>; +} + +/// Tapo API Client. See [examples](https://github.com/mihai-dinculescu/tapo/tree/main/tapo/examples). +/// +/// # Example +/// +/// ```rust,no_run +/// use tapo::ApiClient; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let device = ApiClient::new("tapo-username@example.com", "tapo-password") +/// .l530("192.168.1.100") +/// .await?; +/// +/// device.on().await?; +/// +/// Ok(()) +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct ApiClient { + tapo_username: String, + tapo_password: String, + timeout: Option, + protocol: Option, +} + +/// Tapo API Client constructor. +impl ApiClient { + /// Returns a new instance of [`ApiClient`]. + /// It is cheaper to [`ApiClient::clone`] an existing instance than to create a new one when multiple devices need to be controller. + /// This is because [`ApiClient::clone`] reuses the underlying [`reqwest::Client`]. + /// + /// # Arguments + /// + /// * `tapo_username` - the Tapo username + /// * `tapo_password` - the Tapo password + /// + /// Note: The default connection timeout is 30 seconds. + /// Use [`ApiClient::with_timeout`] to change it. + pub fn new(tapo_username: impl Into, tapo_password: impl Into) -> ApiClient { + Self { + tapo_username: tapo_username.into(), + tapo_password: tapo_password.into(), + timeout: None, + protocol: None, + } + } + + /// Changes the connection timeout from the default value to the given value. + /// + /// # Arguments + /// + /// * `timeout` - The new connection timeout value. + pub fn with_timeout(mut self, timeout: Duration) -> ApiClient { + self.timeout = Some(timeout); + self + } + + /// Discovers one or more devices located at a specified unicast or broadcast IP address. + /// + /// # Arguments + /// * `target` - The IP address at which the discovery will take place. + /// This address can be either a unicast (e.g. `192.168.1.10`) or a + /// broadcast address (e.g. `192.168.1.255`, `255.255.255.255`, etc.). + /// * `timeout_s` - The maximum time to wait for a response from the device(s) in seconds. + /// Must be between `1` and `60`. + pub async fn discover_devices( + self, + target: impl Into, + timeout_s: u64, + ) -> Result { + if !(1..=60).contains(&timeout_s) { + return Err(Error::Validation { + field: "timeout_s".to_string(), + message: "Must be between 1 and 60 seconds".to_string(), + }); + } + + Ok(DeviceDiscovery::new(self, target, Duration::from_secs(timeout_s)).await?) + } +} + +/// Device handler builders. +impl ApiClient { + /// Specializes the given [`ApiClient`] into an authenticated [`GenericDeviceHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .generic_device("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn generic_device( + mut self, + ip_address: impl Into, + ) -> Result { + self.login(ip_address).await?; + + Ok(GenericDeviceHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`LightHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .l510("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn l510(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(LightHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`LightHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .l520("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn l520(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(LightHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`ColorLightHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .l530("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn l530(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(ColorLightHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`ColorLightHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .l535("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn l535(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(ColorLightHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`LightHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .l610("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn l610(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(LightHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`ColorLightHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .l630("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn l630(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(ColorLightHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`RgbLightStripHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .l900("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn l900( + mut self, + ip_address: impl Into, + ) -> Result { + self.login(ip_address).await?; + + Ok(RgbLightStripHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`RgbicLightStripHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .l920("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn l920( + mut self, + ip_address: impl Into, + ) -> Result { + self.login(ip_address).await?; + + Ok(RgbicLightStripHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`RgbicLightStripHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .l930("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn l930( + mut self, + ip_address: impl Into, + ) -> Result { + self.login(ip_address).await?; + + Ok(RgbicLightStripHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`PlugHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .p100("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn p100(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(PlugHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`PlugHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .p105("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn p105(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(PlugHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`PlugEnergyMonitoringHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .p110("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn p110( + mut self, + ip_address: impl Into, + ) -> Result { + self.login(ip_address).await?; + + Ok(PlugEnergyMonitoringHandler::new(Arc::new(RwLock::new( + self, + )))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`PlugEnergyMonitoringHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .p115("192.168.1.100") + /// .await?; + /// device.on().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn p115( + mut self, + ip_address: impl Into, + ) -> Result { + self.login(ip_address).await?; + + Ok(PlugEnergyMonitoringHandler::new(Arc::new(RwLock::new( + self, + )))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`PowerStripHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .p300("192.168.1.100") + /// .await?; + /// let child_device_list = device.get_child_device_list().await?; + /// println!("Child device list: {child_device_list:?}"); + /// # Ok(()) + /// # } + /// ``` + pub async fn p300(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(PowerStripHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`PowerStripEnergyMonitoringHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .p304("192.168.1.100") + /// .await?; + /// let child_device_list = device.get_child_device_list().await?; + /// println!("Child device list: {child_device_list:?}"); + /// # Ok(()) + /// # } + /// ``` + pub async fn p304( + mut self, + ip_address: impl Into, + ) -> Result { + self.login(ip_address).await?; + + Ok(PowerStripEnergyMonitoringHandler::new(Arc::new( + RwLock::new(self), + ))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`PowerStripHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .p306("192.168.1.100") + /// .await?; + /// let child_device_list = device.get_child_device_list().await?; + /// println!("Child device list: {child_device_list:?}"); + /// # Ok(()) + /// # } + /// ``` + pub async fn p306(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(PowerStripHandler::new(Arc::new(RwLock::new(self)))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`PowerStripEnergyMonitoringHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .p316("192.168.1.100") + /// .await?; + /// let child_device_list = device.get_child_device_list().await?; + /// println!("Child device list: {child_device_list:?}"); + /// # Ok(()) + /// # } + /// ``` + pub async fn p316( + mut self, + ip_address: impl Into, + ) -> Result { + self.login(ip_address).await?; + + Ok(PowerStripEnergyMonitoringHandler::new(Arc::new( + RwLock::new(self), + ))) + } + + /// Specializes the given [`ApiClient`] into an authenticated [`HubHandler`]. + /// + /// # Arguments + /// + /// * `ip_address` - the IP address of the device + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .h100("192.168.1.100") + /// .await?; + /// + /// let child_device_list = device.get_child_device_list().await?; + /// println!("Child device list: {child_device_list:?}"); + /// # Ok(()) + /// # } + /// ``` + pub async fn h100(mut self, ip_address: impl Into) -> Result { + self.login(ip_address).await?; + + Ok(HubHandler::new(Arc::new(RwLock::new(self)))) + } +} + +/// Tapo API Client private methods. +impl ApiClient { + pub(crate) async fn login(&mut self, ip_address: impl Into) -> Result<(), Error> { + let url = format!("http://{}/app", ip_address.into()); + debug!("Device url: {url}"); + + let tapo_username = self.tapo_username.clone(); + let tapo_password = self.tapo_password.clone(); + + self.get_protocol_mut()? + .login(url, tapo_username, tapo_password) + .await + } + + pub(crate) async fn refresh_session(&mut self) -> Result<(), Error> { + let tapo_username = self.tapo_username.clone(); + let tapo_password = self.tapo_password.clone(); + + self.get_protocol_mut()? + .refresh_session(tapo_username, tapo_password) + .await + } + + pub(crate) async fn get_supported_alarm_type_list( + &self, + ) -> Result { + let request = TapoRequest::GetSupportedAlarmTypeList(TapoParams::new(EmptyParams)); + + self.get_protocol()? + .execute_request(request, true) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + pub(crate) async fn play_alarm(&self, params: PlayAlarmParams) -> Result<(), Error> { + let request = TapoRequest::PlayAlarm(TapoParams::new(params)); + + self.get_protocol()? + .execute_request::(request, true) + .await?; + + Ok(()) + } + + pub(crate) async fn stop_alarm(&self) -> Result<(), Error> { + let request = TapoRequest::StopAlarm(TapoParams::new(EmptyParams)); + + self.get_protocol()? + .execute_request::(request, true) + .await?; + + Ok(()) + } + + pub(crate) async fn get_device_info(&self) -> Result + where + R: fmt::Debug + DeserializeOwned + TapoResponseExt + DecodableResultExt, + { + debug!("Get Device info..."); + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.get_protocol()? + .execute_request::(request, true) + .await? + .map(|result| result.decode()) + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))? + } + + pub(crate) async fn get_device_usage(&self) -> Result + where + R: fmt::Debug + DeserializeOwned + TapoResponseExt, + { + debug!("Get Device usage..."); + let request = TapoRequest::GetDeviceUsage(TapoParams::new(EmptyParams)); + + self.get_protocol()? + .execute_request(request, true) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + pub(crate) async fn set_lighting_effect( + &self, + lighting_effect: LightingEffect, + ) -> Result<(), Error> { + debug!("Lighting effect will change to: {lighting_effect:?}"); + + let request = TapoRequest::SetLightingEffect(Box::new( + TapoParams::new(lighting_effect) + .set_request_time_mils()? + .set_terminal_uuid(TERMINAL_UUID), + )); + + self.get_protocol()? + .execute_request::(request, true) + .await?; + + Ok(()) + } + + pub(crate) async fn get_energy_usage(&self) -> Result { + debug!("Get Energy usage..."); + let request = TapoRequest::GetEnergyUsage(TapoParams::new(EmptyParams)); + + self.get_protocol()? + .execute_request(request, true) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + pub(crate) async fn get_current_power(&self) -> Result { + debug!("Get Current power..."); + let request = TapoRequest::GetCurrentPower(TapoParams::new(EmptyParams)); + + self.get_protocol()? + .execute_request(request, true) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + pub(crate) async fn get_energy_data( + &self, + interval: EnergyDataInterval, + ) -> Result { + debug!("Get Energy data..."); + let params = GetEnergyDataParams::new(interval); + let request = TapoRequest::GetEnergyData(TapoParams::new(params)); + + self.get_protocol()? + .execute_request::(request, true) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.try_into())? + } + + pub(crate) async fn get_power_data( + &self, + interval: PowerDataInterval, + ) -> Result { + debug!("Get Power data..."); + let params = GetPowerDataParams::new(interval); + let request = TapoRequest::GetPowerData(TapoParams::new(params)); + + self.get_protocol()? + .execute_request::(request, true) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.try_into())? + } + + pub(crate) async fn get_child_device_list(&self, start_index: u64) -> Result + where + R: fmt::Debug + DeserializeOwned + TapoResponseExt + DecodableResultExt, + { + debug!("Get Child device list starting with index {start_index}..."); + let request = TapoRequest::GetChildDeviceList(TapoParams::new( + GetChildDeviceListParams::new(start_index), + )); + + self.get_protocol()? + .execute_request::(request, true) + .await? + .map(|result| result.decode()) + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))? + } + + pub(crate) async fn get_child_device_component_list(&self) -> Result + where + R: fmt::Debug + DeserializeOwned + TapoResponseExt + DecodableResultExt, + { + debug!("Get Child device component list..."); + let request = TapoRequest::GetChildDeviceComponentList(TapoParams::new(EmptyParams)); + + self.get_protocol()? + .execute_request::(request, true) + .await? + .map(|result| result.decode()) + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))? + } + + pub(crate) async fn control_child( + &self, + device_id: String, + child_request: TapoRequest, + ) -> Result, Error> + where + R: fmt::Debug + DeserializeOwned + TapoResponseExt, + { + debug!("Control child..."); + let params = MultipleRequestParams::new(vec![child_request]); + let request = TapoRequest::MultipleRequest(Box::new(TapoParams::new(params))); + + let params = ControlChildParams::new(device_id, request); + let request = TapoRequest::ControlChild(Box::new(TapoParams::new(params))); + + let responses = self + .get_protocol()? + .execute_request::>>(request, true) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))? + .response_data + .result + .responses; + + let response = responses + .into_iter() + .next() + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))?; + + validate_response(&response)?; + + Ok(response.result) + } + + /// Gets countdown timer rules. + pub(crate) async fn get_countdown_rules(&self) -> Result { + debug!("Get Countdown rules..."); + let request = TapoRequest::GetCountdownRules(TapoParams::new(GetRulesParams::default())); + + self.get_protocol()? + .execute_request(request, true) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Gets schedule rules. + pub(crate) async fn get_schedule_rules(&self) -> Result { + debug!("Get Schedule rules..."); + let request = TapoRequest::GetScheduleRules(TapoParams::new(GetRulesParams::default())); + + self.get_protocol()? + .execute_request(request, true) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Gets next scheduled event. + pub(crate) async fn get_next_event(&self) -> Result { + debug!("Get Next event..."); + let request = TapoRequest::GetNextEvent(TapoParams::new(EmptyParams)); + + self.get_protocol()? + .execute_request(request, true) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + fn get_protocol_mut(&mut self) -> Result<&mut TapoProtocol, Error> { + if self.protocol.is_none() { + let timeout = self.timeout.unwrap_or_else(|| Duration::from_secs(30)); + + let client = Client::builder() + .http1_title_case_headers() + .timeout(timeout) + .build()?; + let protocol = TapoProtocol::new(client); + self.protocol.replace(protocol); + } + + self.protocol.as_mut().ok_or_else(|| { + Error::Other(anyhow::anyhow!( + "The protocol should have been initialized already" + )) + }) + } + + fn get_protocol(&self) -> Result<&TapoProtocol, Error> { + self.protocol.as_ref().ok_or_else(|| { + Error::Other(anyhow::anyhow!( + "The protocol should have been initialized already" + )) + }) + } +} + +#[async_trait] +impl ApiClientExt for ApiClient { + async fn set_device_info(&self, device_info_params: serde_json::Value) -> Result<(), Error> { + debug!("Device info will change to: {device_info_params:?}"); + + let set_device_info_request = TapoRequest::SetDeviceInfo(Box::new( + TapoParams::new(device_info_params) + .set_request_time_mils()? + .set_terminal_uuid(TERMINAL_UUID), + )); + + self.get_protocol()? + .execute_request::(set_device_info_request, true) + .await?; + + Ok(()) + } + + async fn device_reboot(&self, delay: u16) -> Result<(), Error> { + debug!("Device reboot..."); + let request = TapoRequest::DeviceReboot(TapoParams::new(DeviceRebootParams::new(delay))); + + self.get_protocol()? + .execute_request::(request, true) + .await?; + + Ok(()) + } + + async fn device_reset(&self) -> Result<(), Error> { + debug!("Device reset..."); + let request = TapoRequest::DeviceReset(TapoParams::new(EmptyParams)); + + self.get_protocol()? + .execute_request::(request, true) + .await?; + + Ok(()) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/capabilities.rs b/agents/tapo/tapo-fork/tapo/src/api/capabilities.rs new file mode 100644 index 0000000..39eafaf --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/capabilities.rs @@ -0,0 +1,3 @@ +mod device_management_ext; + +pub use device_management_ext::*; diff --git a/agents/tapo/tapo-fork/tapo/src/api/capabilities/device_management_ext.rs b/agents/tapo/tapo-fork/tapo/src/api/capabilities/device_management_ext.rs new file mode 100644 index 0000000..10ed65a --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/capabilities/device_management_ext.rs @@ -0,0 +1,35 @@ +use async_trait::async_trait; + +use crate::Error; +use crate::api::HandlerExt; + +/// Extension trait for device management capabilities like `device_reboot` and `device_reset`. +#[async_trait] +pub trait DeviceManagementExt: HandlerExt { + /// *Reboots* the device. + /// + /// Notes: + /// * Using a very small delay (e.g. 0 seconds) may cause a `ConnectionReset` or `TimedOut` error as the device reboots immediately. + /// * Using a larger delay (e.g. 2-3 seconds) allows the device to respond before rebooting, reducing the chance of errors. + /// * With larger delays, the method completes successfully before the device reboots. + /// However, subsequent commands may fail if sent during the reboot process or before the device reconnects to the network. + /// + /// # Arguments + /// + /// * `delay_s` - The delay in seconds before the device is rebooted. + async fn device_reboot(&self, delay_s: u16) -> Result<(), Error> { + self.get_client().await.device_reboot(delay_s).await + } + + /// *Hardware resets* the device. + /// + /// **Warning**: This action will reset the device to its factory settings. + /// The connection to the Wi-Fi network and the Tapo app will be lost, + /// and the device will need to be reconfigured. + /// + /// This feature is especially useful when the device is difficult to access + /// and requires reconfiguration. + async fn device_reset(&self) -> Result<(), Error> { + self.get_client().await.device_reset().await + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/child_devices.rs b/agents/tapo/tapo-fork/tapo/src/api/child_devices.rs new file mode 100644 index 0000000..068418f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/child_devices.rs @@ -0,0 +1,17 @@ +mod ke100_handler; +mod power_strip_plug_energy_monitoring_handler; +mod power_strip_plug_handler; +mod s200b_handler; +mod t100_handler; +mod t110_handler; +mod t300_handler; +mod t31x_handler; + +pub use ke100_handler::*; +pub use power_strip_plug_energy_monitoring_handler::*; +pub use power_strip_plug_handler::*; +pub use s200b_handler::*; +pub use t31x_handler::*; +pub use t100_handler::*; +pub use t110_handler::*; +pub use t300_handler::*; diff --git a/agents/tapo/tapo-fork/tapo/src/api/child_devices/ke100_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/child_devices/ke100_handler.rs new file mode 100644 index 0000000..c6e106f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/child_devices/ke100_handler.rs @@ -0,0 +1,196 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::api::ApiClient; +use crate::error::{Error, TapoResponseError}; +use crate::requests::TemperatureUnitKE100; +use crate::requests::{EmptyParams, TapoParams, TapoRequest, TrvSetDeviceInfoParams}; +use crate::responses::{DecodableResultExt, KE100Result}; + +/// Handler for the [KE100](https://www.tp-link.com/en/search/?q=KE100) devices. +pub struct KE100Handler { + client: Arc>, + device_id: String, +} + +impl KE100Handler { + pub(crate) fn new(client: Arc>, device_id: String) -> Self { + Self { client, device_id } + } + + /// Returns *device info* as [`KE100Result`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + pub async fn get_device_info(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.decode())? + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Sets *child protection* on the device to *on* or *off*. + /// + /// # Arguments + /// + /// * `on` + pub async fn set_child_protection(&self, on: bool) -> Result<(), Error> { + let json = serde_json::to_value(TrvSetDeviceInfoParams::new().child_protection(on)?)?; + let request = TapoRequest::SetDeviceInfo(Box::new(TapoParams::new(json))); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await?; + + Ok(()) + } + + /// Sets *frost protection* on the device to *on* or *off*. + /// + /// # Arguments + /// + /// * `on` + pub async fn set_frost_protection(&self, on: bool) -> Result<(), Error> { + let json = serde_json::to_value(TrvSetDeviceInfoParams::new().frost_protection_on(on)?)?; + let request = TapoRequest::SetDeviceInfo(Box::new(TapoParams::new(json))); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await?; + + Ok(()) + } + + /// Sets the *maximum control temperature*. + /// + /// # Arguments + /// + /// * `value` + /// * `unit` + pub async fn set_max_control_temperature( + &self, + value: u8, + unit: TemperatureUnitKE100, + ) -> Result<(), Error> { + let json = serde_json::to_value( + TrvSetDeviceInfoParams::new().max_control_temperature(value, unit)?, + )?; + let request = TapoRequest::SetDeviceInfo(Box::new(TapoParams::new(json))); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await?; + + Ok(()) + } + + /// Sets the *minimum control temperature*. + /// + /// # Arguments + /// + /// * `value` + /// * `unit` + pub async fn set_min_control_temperature( + &self, + value: u8, + unit: TemperatureUnitKE100, + ) -> Result<(), Error> { + let json = serde_json::to_value( + TrvSetDeviceInfoParams::new().min_control_temperature(value, unit)?, + )?; + let request = TapoRequest::SetDeviceInfo(Box::new(TapoParams::new(json))); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await?; + + Ok(()) + } + + /// Sets the *target temperature*. + /// + /// # Arguments + /// + /// * `value` - between `min_control_temperature` and `max_control_temperature` + /// * `unit` + pub async fn set_target_temperature( + &self, + value: u8, + unit: TemperatureUnitKE100, + ) -> Result<(), Error> { + let device_info = self.get_device_info().await?; + + if value < device_info.min_control_temperature + || value > device_info.max_control_temperature + { + return Err(Error::Validation { + field: "target_temperature".to_string(), + message: format!( + "Target temperature must be between {} (min_control_temperature) and {} (max_control_temperature)", + device_info.min_control_temperature, device_info.max_control_temperature + ), + }); + } + + let json = + serde_json::to_value(TrvSetDeviceInfoParams::new().target_temperature(value, unit)?)?; + let request = TapoRequest::SetDeviceInfo(Box::new(TapoParams::new(json))); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await?; + + Ok(()) + } + + /// Sets the *temperature offset*. + /// + /// # Arguments + /// + /// * `value` - between -10 and 10 + /// * `unit` + pub async fn set_temperature_offset( + &self, + value: i8, + unit: TemperatureUnitKE100, + ) -> Result<(), Error> { + let json = + serde_json::to_value(TrvSetDeviceInfoParams::new().temperature_offset(value, unit)?)?; + let request = TapoRequest::SetDeviceInfo(Box::new(TapoParams::new(json))); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await?; + + Ok(()) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/child_devices/power_strip_plug_energy_monitoring_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/child_devices/power_strip_plug_energy_monitoring_handler.rs new file mode 100644 index 0000000..80e25d1 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/child_devices/power_strip_plug_energy_monitoring_handler.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::api::ApiClient; +use crate::error::{Error, TapoResponseError}; +use crate::requests::{ + EmptyParams, EnergyDataInterval, GenericSetDeviceInfoParams, GetEnergyDataParams, + GetPowerDataParams, PowerDataInterval, TapoParams, TapoRequest, +}; +use crate::responses::{ + CurrentPowerResult, DecodableResultExt, DeviceUsageEnergyMonitoringResult, EnergyDataResult, + EnergyDataResultRaw, EnergyUsageResult, PowerDataResult, PowerDataResultRaw, + PowerStripPlugEnergyMonitoringResult, +}; + +/// Handler for the [P304M](https://www.tp-link.com/uk/search/?q=P304M) and +/// [P316M](https://www.tp-link.com/us/search/?q=P316M) child plugs. +pub struct PowerStripPlugEnergyMonitoringHandler { + client: Arc>, + device_id: String, +} + +impl PowerStripPlugEnergyMonitoringHandler { + pub(crate) fn new(client: Arc>, device_id: String) -> Self { + Self { client, device_id } + } + + /// Returns *device info* as [`PowerStripPlugEnergyMonitoringResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, + /// try [`PowerStripPlugEnergyMonitoringHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.decode())? + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Turns *on* the device. + pub async fn on(&self) -> Result<(), Error> { + let json = serde_json::to_value(GenericSetDeviceInfoParams::device_on(true)?)?; + let request = TapoRequest::SetDeviceInfo(Box::new(TapoParams::new(json))); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await?; + + Ok(()) + } + + /// Turns *off* the device. + pub async fn off(&self) -> Result<(), Error> { + let json = serde_json::to_value(GenericSetDeviceInfoParams::device_on(false)?)?; + let request = TapoRequest::SetDeviceInfo(Box::new(TapoParams::new(json))); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await?; + + Ok(()) + } + + /// Returns *current power* as [`CurrentPowerResult`]. + pub async fn get_current_power(&self) -> Result { + let request = TapoRequest::GetCurrentPower(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Returns *device usage* as [`DeviceUsageEnergyMonitoringResult`]. + pub async fn get_device_usage(&self) -> Result { + let request = TapoRequest::GetDeviceUsage(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Returns *energy usage* as [`EnergyUsageResult`]. + pub async fn get_energy_usage(&self) -> Result { + let request = TapoRequest::GetEnergyUsage(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Returns *energy data* as [`EnergyDataResult`]. + pub async fn get_energy_data( + &self, + interval: EnergyDataInterval, + ) -> Result { + let params = GetEnergyDataParams::new(interval); + let request = TapoRequest::GetEnergyData(TapoParams::new(params)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.try_into())? + } + + /// Returns *power data* as [`PowerDataResult`]. + pub async fn get_power_data( + &self, + interval: PowerDataInterval, + ) -> Result { + let params = GetPowerDataParams::new(interval); + let request = TapoRequest::GetPowerData(TapoParams::new(params)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.try_into())? + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/child_devices/power_strip_plug_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/child_devices/power_strip_plug_handler.rs new file mode 100644 index 0000000..84a1c63 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/child_devices/power_strip_plug_handler.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::api::ApiClient; +use crate::error::{Error, TapoResponseError}; +use crate::requests::{EmptyParams, GenericSetDeviceInfoParams, TapoParams, TapoRequest}; +use crate::responses::{DecodableResultExt, PowerStripPlugResult}; + +/// Handler for the [P300](https://www.tp-link.com/en/search/?q=P300) and +/// [P306](https://www.tp-link.com/us/search/?q=P306) child plugs. +pub struct PowerStripPlugHandler { + client: Arc>, + device_id: String, +} + +impl PowerStripPlugHandler { + pub(crate) fn new(client: Arc>, device_id: String) -> Self { + Self { client, device_id } + } + + /// Returns *device info* as [`PowerStripPlugResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, + /// try [`PowerStripPlugHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.decode())? + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Turns *on* the device. + pub async fn on(&self) -> Result<(), Error> { + let json = serde_json::to_value(GenericSetDeviceInfoParams::device_on(true)?)?; + let request = TapoRequest::SetDeviceInfo(Box::new(TapoParams::new(json))); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await?; + + Ok(()) + } + + /// Turns *off* the device. + pub async fn off(&self) -> Result<(), Error> { + let json = serde_json::to_value(GenericSetDeviceInfoParams::device_on(false)?)?; + let request = TapoRequest::SetDeviceInfo(Box::new(TapoParams::new(json))); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await?; + + Ok(()) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/child_devices/s200b_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/child_devices/s200b_handler.rs new file mode 100644 index 0000000..a0b7a09 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/child_devices/s200b_handler.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::api::ApiClient; +use crate::error::{Error, TapoResponseError}; +use crate::requests::{EmptyParams, GetTriggerLogsParams, TapoParams, TapoRequest}; +use crate::responses::{DecodableResultExt, S200BResult}; +use crate::responses::{S200BLog, TriggerLogsResult}; + +/// Handler for the [S200B](https://www.tapo.com/en/search/?q=S200B) devices. +pub struct S200BHandler { + client: Arc>, + device_id: String, +} + +impl S200BHandler { + pub(crate) fn new(client: Arc>, device_id: String) -> Self { + Self { client, device_id } + } + + /// Returns *device info* as [`S200BResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + pub async fn get_device_info(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.decode())? + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Returns a list of *trigger logs*. + /// + /// # Arguments + /// + /// * `page_size` - the maximum number of log items to return + /// * `start_id` - the log item `id` from which to start returning results in reverse chronological order (newest first) + /// + /// Use a `start_id` of `0` to get the most recent X logs, where X is capped by `page_size`. + pub async fn get_trigger_logs( + &self, + page_size: u64, + start_id: u64, + ) -> Result, Error> { + let child_params = GetTriggerLogsParams::new(page_size, start_id); + let child_request = TapoRequest::GetTriggerLogs(Box::new(TapoParams::new(child_params))); + + self.client + .read() + .await + .control_child(self.device_id.clone(), child_request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/child_devices/t100_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/child_devices/t100_handler.rs new file mode 100644 index 0000000..cd3a8c8 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/child_devices/t100_handler.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::api::ApiClient; +use crate::error::{Error, TapoResponseError}; +use crate::requests::{EmptyParams, GetTriggerLogsParams, TapoParams, TapoRequest}; +use crate::responses::{DecodableResultExt, T100Result}; +use crate::responses::{T100Log, TriggerLogsResult}; + +/// Handler for the [T100](https://www.tapo.com/en/search/?q=T100) devices. +pub struct T100Handler { + client: Arc>, + device_id: String, +} + +impl T100Handler { + pub(crate) fn new(client: Arc>, device_id: String) -> Self { + Self { client, device_id } + } + + /// Returns *device info* as [`T100Result`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + pub async fn get_device_info(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.decode())? + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Returns a list of *trigger logs*. + /// + /// # Arguments + /// + /// * `page_size` - the maximum number of log items to return + /// * `start_id` - the log item `id` from which to start returning results in reverse chronological order (newest first) + /// + /// Use a `start_id` of `0` to get the most recent X logs, where X is capped by `page_size`. + pub async fn get_trigger_logs( + &self, + page_size: u64, + start_id: u64, + ) -> Result, Error> { + let child_params = GetTriggerLogsParams::new(page_size, start_id); + let child_request = TapoRequest::GetTriggerLogs(Box::new(TapoParams::new(child_params))); + + self.client + .read() + .await + .control_child(self.device_id.clone(), child_request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/child_devices/t110_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/child_devices/t110_handler.rs new file mode 100644 index 0000000..36b841c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/child_devices/t110_handler.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::api::ApiClient; +use crate::error::{Error, TapoResponseError}; +use crate::requests::{EmptyParams, GetTriggerLogsParams, TapoParams, TapoRequest}; +use crate::responses::{DecodableResultExt, T110Result}; +use crate::responses::{T110Log, TriggerLogsResult}; + +/// Handler for the [T110](https://www.tapo.com/en/search/?q=T110) devices. +pub struct T110Handler { + client: Arc>, + device_id: String, +} + +impl T110Handler { + pub(crate) fn new(client: Arc>, device_id: String) -> Self { + Self { client, device_id } + } + + /// Returns *device info* as [`T110Result`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + pub async fn get_device_info(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.decode())? + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Returns a list of *trigger logs*. + /// + /// # Arguments + /// + /// * `page_size` - the maximum number of log items to return + /// * `start_id` - the log item `id` from which to start returning results in reverse chronological order (newest first) + /// + /// Use a `start_id` of `0` to get the most recent X logs, where X is capped by `page_size`. + pub async fn get_trigger_logs( + &self, + page_size: u64, + start_id: u64, + ) -> Result, Error> { + let child_params = GetTriggerLogsParams::new(page_size, start_id); + let child_request = TapoRequest::GetTriggerLogs(Box::new(TapoParams::new(child_params))); + + self.client + .read() + .await + .control_child(self.device_id.clone(), child_request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/child_devices/t300_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/child_devices/t300_handler.rs new file mode 100644 index 0000000..22701d9 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/child_devices/t300_handler.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::api::ApiClient; +use crate::error::{Error, TapoResponseError}; +use crate::requests::{EmptyParams, GetTriggerLogsParams, TapoParams, TapoRequest}; +use crate::responses::{DecodableResultExt, T300Result}; +use crate::responses::{T300Log, TriggerLogsResult}; + +/// Handler for the [T300](https://www.tapo.com/en/search/?q=T300) devices. +pub struct T300Handler { + client: Arc>, + device_id: String, +} + +impl T300Handler { + pub(crate) fn new(client: Arc>, device_id: String) -> Self { + Self { client, device_id } + } + + /// Returns *device info* as [`T300Result`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + pub async fn get_device_info(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.decode())? + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Returns a list of *trigger logs*. + /// + /// # Arguments + /// + /// * `page_size` - the maximum number of log items to return + /// * `start_id` - the log item `id` from which to start returning results in reverse chronological order (newest first) + /// + /// Use a `start_id` of `0` to get the most recent X logs, where X is capped by `page_size`. + pub async fn get_trigger_logs( + &self, + page_size: u64, + start_id: u64, + ) -> Result, Error> { + let child_params = GetTriggerLogsParams::new(page_size, start_id); + let child_request = TapoRequest::GetTriggerLogs(Box::new(TapoParams::new(child_params))); + + self.client + .read() + .await + .control_child::>(self.device_id.clone(), child_request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/child_devices/t31x_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/child_devices/t31x_handler.rs new file mode 100644 index 0000000..3b256c5 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/child_devices/t31x_handler.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::api::ApiClient; +use crate::error::{Error, TapoResponseError}; +use crate::requests::{EmptyParams, TapoParams, TapoRequest}; +use crate::responses::{ + DecodableResultExt, T31XResult, TemperatureHumidityRecords, TemperatureHumidityRecordsRaw, +}; + +/// Handler for the [T310](https://www.tapo.com/en/search/?q=T310) and [T315](https://www.tapo.com/en/search/?q=T315) devices. +pub struct T31XHandler { + client: Arc>, + device_id: String, +} + +impl T31XHandler { + pub(crate) fn new(client: Arc>, device_id: String) -> Self { + Self { client, device_id } + } + + /// Returns *device info* as [`T31XResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + pub async fn get_device_info(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + .map(|result| result.decode())? + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + let request = TapoRequest::GetDeviceInfo(TapoParams::new(EmptyParams)); + + self.client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult)) + } + + /// Returns *temperature and humidity records* from the last 24 hours at 15 minute intervals as [`TemperatureHumidityRecords`]. + pub async fn get_temperature_humidity_records( + &self, + ) -> Result { + let request = + TapoRequest::GetTemperatureHumidityRecords(Box::new(TapoParams::new(EmptyParams))); + + let result = self + .client + .read() + .await + .control_child::(self.device_id.clone(), request) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))?; + + Ok(result.try_into()?) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/color_light_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/color_light_handler.rs new file mode 100644 index 0000000..67f2ccc --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/color_light_handler.rs @@ -0,0 +1,145 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::error::Error; +use crate::requests::{Color, ColorLightSetDeviceInfoParams}; +use crate::responses::{DeviceInfoColorLightResult, DeviceUsageEnergyMonitoringResult}; + +use super::{ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt}; + +/// Handler for the [L530](https://www.tapo.com/en/search/?q=L530), +/// [L535](https://www.tapo.com/en/search/?q=L535) and +/// [L630](https://www.tapo.com/en/search/?q=L630) devices. +#[derive(Debug)] +pub struct ColorLightHandler { + client: Arc>, +} + +impl ColorLightHandler { + pub(crate) fn new(client: Arc>) -> Self { + Self { client } + } + + /// Refreshes the authentication session. + pub async fn refresh_session(&mut self) -> Result<&mut Self, Error> { + self.client.write().await.refresh_session().await?; + Ok(self) + } + + /// Turns *on* the device. + pub async fn on(&self) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new().on().send(self).await + } + + /// Turns *off* the device. + pub async fn off(&self) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new().off().send(self).await + } + + /// Returns *device info* as [`DeviceInfoColorLightResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, try [`ColorLightHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device usage* as [`DeviceUsageEnergyMonitoringResult`]. + pub async fn get_device_usage(&self) -> Result { + self.client.read().await.get_device_usage().await + } + + /// Returns a [`ColorLightSetDeviceInfoParams`] builder that allows multiple properties to be set in a single request. + /// [`ColorLightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # use tapo::requests::Color; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// # .l530("192.168.1.100") + /// # .await?; + /// device + /// .set() + /// .brightness(50) + /// .color(Color::HotPink) + /// .send(&device) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn set(&self) -> ColorLightSetDeviceInfoParams { + ColorLightSetDeviceInfoParams::new() + } + + /// Sets the *brightness* and turns *on* the device. + /// + /// # Arguments + /// + /// * `brightness` - between 1 and 100 + pub async fn set_brightness(&self, brightness: u8) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .brightness(brightness) + .send(self) + .await + } + + /// Sets the *color* and turns *on* the device. + /// + /// # Arguments + /// + /// * `color` - one of [crate::requests::Color] as defined in the Google Home app + pub async fn set_color(&self, color: Color) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .color(color) + .send(self) + .await + } + + /// Sets the *hue*, *saturation* and turns *on* the device. + /// + /// # Arguments + /// + /// * `hue` - between 0 and 360 + /// * `saturation` - between 1 and 100 + pub async fn set_hue_saturation(&self, hue: u16, saturation: u8) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .hue_saturation(hue, saturation) + .send(self) + .await + } + + /// Sets the *color temperature* and turns *on* the device. + /// + /// # Arguments + /// + /// * `color_temperature` - between 2500 and 6500 + pub async fn set_color_temperature(&self, color_temperature: u16) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .color_temperature(color_temperature) + .send(self) + .await + } +} + +#[async_trait] +impl HandlerExt for ColorLightHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ) + } +} + +impl DeviceManagementExt for ColorLightHandler {} diff --git a/agents/tapo/tapo-fork/tapo/src/api/discovery.rs b/agents/tapo/tapo-fork/tapo/src/api/discovery.rs new file mode 100644 index 0000000..28e7a0d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/discovery.rs @@ -0,0 +1,6 @@ +mod aes_discovery_query_generator; +mod device_discovery; +mod discovery_result; + +pub use device_discovery::*; +pub use discovery_result::*; diff --git a/agents/tapo/tapo-fork/tapo/src/api/discovery/aes_discovery_query_generator.rs b/agents/tapo/tapo-fork/tapo/src/api/discovery/aes_discovery_query_generator.rs new file mode 100644 index 0000000..3ae1c7c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/discovery/aes_discovery_query_generator.rs @@ -0,0 +1,78 @@ +use anyhow::Context; +use crc32fast::Hasher; +use rsa::pkcs1::EncodeRsaPublicKey; +use rsa::rand_core::{OsRng, RngCore}; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use serde_json::json; + +struct KeyPair { + public_key: RsaPublicKey, +} + +impl KeyPair { + fn new(key_size: usize) -> anyhow::Result { + let private_key = RsaPrivateKey::new(&mut OsRng, key_size) + .context("Failed to generate RSA private key")?; + let public_key = RsaPublicKey::from(&private_key); + Ok(KeyPair { public_key }) + } + + fn get_public_pem(&self) -> anyhow::Result { + self.public_key + .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF) + .context("Failed to convert public key to PEM") + } +} + +pub(crate) struct AesDiscoveryQueryGenerator { + key_pair: KeyPair, +} + +impl AesDiscoveryQueryGenerator { + pub(crate) fn new() -> anyhow::Result { + let key_pair = KeyPair::new(1024)?; + Ok(AesDiscoveryQueryGenerator { key_pair }) + } + + pub(crate) fn generate(&mut self) -> anyhow::Result> { + let mut secret = [0u8; 4]; + OsRng.fill_bytes(&mut secret); + + let key_payload = json!({ + "params": { + "rsa_key": self.key_pair.get_public_pem()? + } + }); + + let key_payload_bytes = + serde_json::to_vec(&key_payload).context("Failed to serialize the key payload Json")?; + let version = 2u8; + let msg_type = 0u8; + let op_code = 1u16; + let msg_size = key_payload_bytes.len() as u16; + let flags = 17u8; + let padding_byte = 0u8; + let device_serial = u32::from_be_bytes(secret); + let initial_crc = 0x5A6B7C8Di32; + + let mut disco_header = vec![]; + disco_header.extend_from_slice(&version.to_be_bytes()); + disco_header.extend_from_slice(&msg_type.to_be_bytes()); + disco_header.extend_from_slice(&op_code.to_be_bytes()); + disco_header.extend_from_slice(&msg_size.to_be_bytes()); + disco_header.extend_from_slice(&flags.to_be_bytes()); + disco_header.extend_from_slice(&padding_byte.to_be_bytes()); + disco_header.extend_from_slice(&device_serial.to_be_bytes()); + disco_header.extend_from_slice(&initial_crc.to_be_bytes()); + + let mut query = disco_header; + query.extend_from_slice(&key_payload_bytes); + + let mut hasher = Hasher::new(); + hasher.update(&query); + let crc = hasher.finalize().to_be_bytes(); + query[12..16].copy_from_slice(&crc); + + Ok(query) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/discovery/device_discovery.rs b/agents/tapo/tapo-fork/tapo/src/api/discovery/device_discovery.rs new file mode 100644 index 0000000..a219802 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/discovery/device_discovery.rs @@ -0,0 +1,221 @@ +use log::{Level, debug, info, log_enabled, trace}; +use std::net::{IpAddr, SocketAddr}; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context as TaskContext, Poll}; +use tokio::net::UdpSocket; +use tokio::sync::mpsc::{self, Receiver}; +use tokio::sync::{Mutex, RwLock}; +use tokio::time::Duration; +use tokio_stream::Stream; + +pub use tokio_stream::StreamExt; + +use super::aes_discovery_query_generator::AesDiscoveryQueryGenerator; +use super::discovery_result::DiscoveryResult; +use crate::{ApiClient, Error}; + +// Attempts discovery every 3 seconds. +const DISCOVERY_INTERVAL: Duration = Duration::from_secs(3); + +/// Device discovery process for Tapo devices. +pub struct DeviceDiscovery { + rx: Receiver>>, +} + +impl DeviceDiscovery { + pub(crate) async fn new( + client: ApiClient, + target: impl Into, + timeout: Duration, + ) -> anyhow::Result { + let target = SocketAddr::new(target.into().parse()?, 20002); + + let bind_address = match target.ip() { + IpAddr::V4(_) => "0.0.0.0:0", // IPv4 + IpAddr::V6(_) => "[::]:0", // IPv6 + }; + + let transport = UdpSocket::bind(bind_address).await?; + transport.set_broadcast(true)?; + let transport = Arc::new(transport); + + let (tx, rx) = mpsc::channel(1024); + let seen_addrs = Arc::new(Mutex::new(vec![])); + + let discovery_transport = transport.clone(); + let discovery_seen_addrs = seen_addrs.clone(); + let discovery_tx = tx.clone(); + + let client: Arc> = Arc::new(RwLock::new(client)); + + tokio::spawn(async move { + let result = tokio::time::timeout( + timeout, + Self::send_discovery_query( + discovery_transport, + target, + discovery_seen_addrs, + discovery_tx.clone(), + ), + ) + .await; + + if result.is_err() { + trace!("Discovery query timed out"); + } + }); + + tokio::spawn(async move { + let result = tokio::time::timeout( + timeout, + Self::receive_discovery_response(client, transport, target, seen_addrs, tx.clone()), + ) + .await; + + if result.is_err() { + trace!("Discovery response timed out"); + } + }); + + Ok(Self { rx }) + } + + async fn send_discovery_query( + transport: Arc, + target: SocketAddr, + seen_addrs: Arc>>, + tx: mpsc::Sender>>, + ) { + let error_handling_tx = tx.clone(); + + let result = async move { + let aes_discovery_query = AesDiscoveryQueryGenerator::new()?.generate()?; + + loop { + if tx.is_closed() { + info!("Channel closed, stopping discovery queries"); + break; + } + + let seen_addrs = seen_addrs.lock().await; + if seen_addrs.contains(&target) { + trace!("Target found, stopping discovery queries"); + break; + } + drop(seen_addrs); + + transport.send_to(&aes_discovery_query, target).await?; + + tokio::time::sleep(DISCOVERY_INTERVAL).await; + } + + trace!("Discovery queries finished"); + + Ok::<_, anyhow::Error>(()) + } + .await; + + if let Err(e) = result { + let _ = error_handling_tx.send(Some(Err(e.into()))).await; + } + } + + async fn receive_discovery_response( + client: Arc>, + transport: Arc, + target: SocketAddr, + seen_addrs: Arc>>, + tx: mpsc::Sender>>, + ) { + loop { + if tx.is_closed() { + trace!("Channel closed, stopping discovery responses"); + break; + } + + if tokio::time::timeout(Duration::from_millis(100), transport.readable()) + .await + .is_err() + { + continue; + } + + let mut buf = [0; 2048]; + + // Try to recv data, this may still fail with `WouldBlock` + // if the readiness event is a false positive. + match transport.try_recv_from(&mut buf) { + Ok((size, addr)) => { + let mut seen_addrs = seen_addrs.lock().await; + if seen_addrs.contains(&addr) { + continue; + } else { + seen_addrs.push(addr); + } + drop(seen_addrs); + + if size > 16 && log_enabled!(Level::Debug) { + debug!("Received discovery response from {addr:?}"); + let message: String = String::from_utf8_lossy(&buf[16..]).to_string(); + debug!("Discovery response message: {message}"); + } + + tokio::spawn(Self::process_discovery_response( + client.clone(), + addr.ip(), + target.ip(), + tx.clone(), + )); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + continue; + } + Err(e) => { + let error = + anyhow::Error::from(e).context("Failed to receive discovery response"); + tx.send(Some(Err(error.into()))).await.ok(); + break; + } + } + } + } + + async fn process_discovery_response( + client: Arc>, + ip_addr: IpAddr, + target: IpAddr, + tx: mpsc::Sender>>, + ) { + let client = client.read().await.clone(); + + let result = DiscoveryResult::new(client, ip_addr).await; + + let _ = tx.send(Some(result)).await; + + if ip_addr == target { + debug!("Target found, stopping discovery responses"); + let _ = tx.send(None).await; + } + } +} + +impl Stream for DeviceDiscovery { + type Item = Result; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + ) -> Poll>> { + match Pin::new(&mut self.rx).poll_recv(cx) { + Poll::Ready(result) => match result { + Some(result) => Poll::Ready(result), + None => { + trace!("Discovery stream closed"); + Poll::Ready(None) + } + }, + Poll::Pending => Poll::Pending, + } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/discovery/discovery_result.rs b/agents/tapo/tapo-fork/tapo/src/api/discovery/discovery_result.rs new file mode 100644 index 0000000..8419cf3 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/discovery/discovery_result.rs @@ -0,0 +1,194 @@ +use std::net::IpAddr; + +use anyhow::Context; + +use crate::responses::{ + DecodableResultExt, DeviceInfoColorLightResult, DeviceInfoGenericResult, DeviceInfoHubResult, + DeviceInfoLightResult, DeviceInfoPlugEnergyMonitoringResult, DeviceInfoPlugResult, + DeviceInfoPowerStripResult, DeviceInfoRgbLightStripResult, DeviceInfoRgbicLightStripResult, +}; +use crate::{ + ApiClient, ColorLightHandler, Error, GenericDeviceHandler, HubHandler, LightHandler, + PlugEnergyMonitoringHandler, PlugHandler, PowerStripEnergyMonitoringHandler, PowerStripHandler, + RgbLightStripHandler, RgbicLightStripHandler, +}; + +#[derive(Debug)] +/// Result of the device discovery process. +pub enum DiscoveryResult { + /// A Generic Tapo device. + /// + /// If you believe this device is already supported, or would like to explore adding support for a currently + /// unsupported model, please [open an issue on GitHub](https://github.com/mihai-dinculescu/tapo/issues) + /// to start the discussion. + GenericDevice { + /// Device info of a Generic Tapo device. + /// + /// If you believe this device is already supported, or would like to explore adding support for a currently + /// unsupported model, please [open an issue on GitHub](https://github.com/mihai-dinculescu/tapo/issues) + /// to start the discussion. + device_info: Box, + /// Handler for generic devices. It provides the functionality common to all Tapo [devices](https://www.tapo.com/en/). + /// + /// If you believe this device is already supported, or would like to explore adding support for a currently + /// unsupported model, please [open an issue on GitHub](https://github.com/mihai-dinculescu/tapo/issues) + /// to start the discussion. + handler: GenericDeviceHandler, + }, + /// Tapo L510, L520 and L610 devices. + Light { + /// Device info of Tapo L510, L520 and L610. + device_info: Box, + /// Handler for the [L510](https://www.tapo.com/en/search/?q=L510), + /// [L520](https://www.tapo.com/en/search/?q=L520) and + /// [L610](https://www.tapo.com/en/search/?q=L610) devices. + handler: LightHandler, + }, + /// Tapo L530, L535 and L630 devices. + ColorLight { + /// Device info of Tapo L530, L535 and L630. + device_info: Box, + /// Handler for the [L530](https://www.tapo.com/en/search/?q=L530), + /// [L535](https://www.tapo.com/en/search/?q=L535) and + /// [L630](https://www.tapo.com/en/search/?q=L630) devices. + handler: ColorLightHandler, + }, + /// Tapo L900 devices. + RgbLightStrip { + /// Device info of Tapo L900. + device_info: Box, + /// Handler for the [L900](https://www.tapo.com/en/search/?q=L900) devices. + handler: RgbLightStripHandler, + }, + /// Tapo L920 and L930 devices. + RgbicLightStrip { + /// Device info of Tapo L920 and L930. + device_info: Box, + /// Handler for the [L920](https://www.tapo.com/en/search/?q=L920) and + /// [L930](https://www.tapo.com/en/search/?q=L930) devices. + handler: RgbicLightStripHandler, + }, + /// Tapo P100 and P105 devices. + Plug { + /// Device info of Tapo P100 and P105. + device_info: Box, + /// Handler for the [P100](https://www.tapo.com/en/search/?q=P100) and + /// [P105](https://www.tapo.com/en/search/?q=P105) devices. + handler: PlugHandler, + }, + /// Tapo P110, P110M and P115 devices. + PlugEnergyMonitoring { + /// Device info of Tapo P110, P110M and P115. + device_info: Box, + /// Handler for the [P110](https://www.tapo.com/en/search/?q=P110), + /// [P110M](https://www.tapo.com/en/search/?q=P110M) and + /// [P115](https://www.tapo.com/en/search/?q=P115) devices. + handler: PlugEnergyMonitoringHandler, + }, + /// Tapo P300 and P306 devices. + PowerStrip { + /// Device info of Tapo P300 and P306. + device_info: Box, + /// Handler for the [P300](https://www.tapo.com/en/search/?q=P300) and + /// [P306](https://www.tp-link.com/us/search/?q=P306) devices. + handler: PowerStripHandler, + }, + /// Tapo P304M and P316M devices. + PowerStripEnergyMonitoring { + /// Device info of Tapo P304M and P316M. + device_info: Box, + /// Handler for the [P304M](https://www.tp-link.com/uk/search/?q=P304M) and + /// [P316M](https://www.tp-link.com/us/search/?q=P316M) devices. + handler: PowerStripEnergyMonitoringHandler, + }, + /// Tapo H100 devices. + Hub { + /// Device info of Tapo H100. + device_info: Box, + /// Handler for the [H100](https://www.tapo.com/en/search/?q=H100) devices. + handler: HubHandler, + }, +} + +macro_rules! map_device_model { + ($discovery_result_type:ident, $device_info_type:ident, $device_info:expr, $handler:expr) => {{ + DiscoveryResult::$discovery_result_type { + device_info: Box::new( + serde_json::from_value::<$device_info_type>($device_info)?.decode()?, + ), + handler: $handler.into(), + } + }}; +} + +impl DiscoveryResult { + pub(crate) async fn new(client: ApiClient, ip_addr: IpAddr) -> Result { + let handler = client.generic_device(ip_addr.to_string()).await?; + + let device_info = handler.get_device_info_json().await?; + + let model = device_info + .as_object() + .context("Expected device_info result to be an object")? + .get_key_value("model") + .context("Expected device_info to contain the model field")? + .1 + .as_str() + .context("Expected device_info model field to have a string value")?; + + let result = match model { + "L510" | "L520" | "L610" => { + map_device_model!(Light, DeviceInfoLightResult, device_info, handler) + } + "L530" | "L530 Series" | "L535" | "L535B" | "L630" => { + map_device_model!(ColorLight, DeviceInfoColorLightResult, device_info, handler) + } + "L900" => { + map_device_model!( + RgbLightStrip, + DeviceInfoRgbLightStripResult, + device_info, + handler + ) + } + "L920" | "L930" => { + map_device_model!( + RgbicLightStrip, + DeviceInfoRgbicLightStripResult, + device_info, + handler + ) + } + "P100" | "P105" => { + map_device_model!(Plug, DeviceInfoPlugResult, device_info, handler) + } + "P110" | "P110M" | "P115" => { + map_device_model!( + PlugEnergyMonitoring, + DeviceInfoPlugEnergyMonitoringResult, + device_info, + handler + ) + } + "P300" | "P306" => { + map_device_model!(PowerStrip, DeviceInfoPowerStripResult, device_info, handler) + } + "P304M" | "P316M" => { + map_device_model!( + PowerStripEnergyMonitoring, + DeviceInfoPowerStripResult, + device_info, + handler + ) + } + "H100" => { + map_device_model!(Hub, DeviceInfoHubResult, device_info, handler) + } + _ => { + map_device_model!(GenericDevice, DeviceInfoGenericResult, device_info, handler) + } + }; + + Ok(result) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/generic_device_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/generic_device_handler.rs new file mode 100644 index 0000000..05bccfc --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/generic_device_handler.rs @@ -0,0 +1,125 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::api::{ApiClient, ApiClientExt}; +use crate::error::Error; +use crate::requests::GenericSetDeviceInfoParams; +use crate::responses::DeviceInfoGenericResult; + +use super::{ + ColorLightHandler, HandlerExt, HubHandler, LightHandler, PlugEnergyMonitoringHandler, + PlugHandler, PowerStripEnergyMonitoringHandler, PowerStripHandler, RgbLightStripHandler, + RgbicLightStripHandler, +}; + +/// Handler for generic devices. It provides the functionality common to all Tapo [devices](https://www.tapo.com/en/). +/// +/// If you'd like to propose support for a device that isn't currently supported, +/// please [open an issue on GitHub](https://github.com/mihai-dinculescu/tapo/issues) to start the conversation. +#[derive(Debug)] +pub struct GenericDeviceHandler { + client: Arc>, +} + +impl GenericDeviceHandler { + pub(crate) fn new(client: Arc>) -> Self { + Self { client } + } + + /// Refreshes the authentication session. + pub async fn refresh_session(&mut self) -> Result<&mut Self, Error> { + self.client.write().await.refresh_session().await?; + Ok(self) + } + + /// Turns *on* the device. + pub async fn on(&self) -> Result<(), Error> { + let json = serde_json::to_value(GenericSetDeviceInfoParams::device_on(true)?)?; + self.client.read().await.set_device_info(json).await + } + + /// Turns *off* the device. + pub async fn off(&self) -> Result<(), Error> { + let json = serde_json::to_value(GenericSetDeviceInfoParams::device_on(false)?)?; + self.client.read().await.set_device_info(json).await + } + + /// Returns *device info* as [`DeviceInfoGenericResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, try [`GenericDeviceHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + self.client.read().await.get_device_info().await + } +} + +#[async_trait] +impl HandlerExt for GenericDeviceHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ) + } +} + +impl From for LightHandler { + fn from(value: GenericDeviceHandler) -> Self { + LightHandler::new(value.client) + } +} + +impl From for ColorLightHandler { + fn from(value: GenericDeviceHandler) -> Self { + ColorLightHandler::new(value.client) + } +} + +impl From for RgbLightStripHandler { + fn from(value: GenericDeviceHandler) -> Self { + RgbLightStripHandler::new(value.client) + } +} + +impl From for RgbicLightStripHandler { + fn from(value: GenericDeviceHandler) -> Self { + RgbicLightStripHandler::new(value.client) + } +} + +impl From for PlugHandler { + fn from(value: GenericDeviceHandler) -> Self { + PlugHandler::new(value.client) + } +} + +impl From for PlugEnergyMonitoringHandler { + fn from(value: GenericDeviceHandler) -> Self { + PlugEnergyMonitoringHandler::new(value.client) + } +} + +impl From for PowerStripHandler { + fn from(value: GenericDeviceHandler) -> Self { + PowerStripHandler::new(value.client) + } +} + +impl From for PowerStripEnergyMonitoringHandler { + fn from(value: GenericDeviceHandler) -> Self { + PowerStripEnergyMonitoringHandler::new(value.client) + } +} + +impl From for HubHandler { + fn from(value: GenericDeviceHandler) -> Self { + HubHandler::new(value.client) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/handler_ext.rs b/agents/tapo/tapo-fork/tapo/src/api/handler_ext.rs new file mode 100644 index 0000000..61f1d64 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/handler_ext.rs @@ -0,0 +1,11 @@ +use async_trait::async_trait; +use tokio::sync::RwLockReadGuard; + +use super::ApiClientExt; + +/// Implemented by all device handlers. +#[async_trait] +pub trait HandlerExt: Send + Sync { + /// Returns the client used by this handler. + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt>; +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/hub_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/hub_handler.rs new file mode 100644 index 0000000..fd2d7f2 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/hub_handler.rs @@ -0,0 +1,383 @@ +use async_trait::async_trait; +use std::sync::Arc; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::error::Error; +use crate::requests::{AlarmDuration, AlarmRingtone, AlarmVolume, PlayAlarmParams}; +use crate::responses::{ChildDeviceHubResult, ChildDeviceListHubResult, DeviceInfoHubResult}; + +use super::{ + ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt, KE100Handler, S200BHandler, + T31XHandler, T100Handler, T110Handler, T300Handler, +}; + +macro_rules! get_device_id { + ($self:expr, $identifier:expr, $($value:path),+) => {{ + let children = $self.get_child_device_list().await?; + + match $identifier { + HubDevice::ByDeviceId(device_id) => children + .iter() + .filter_map(|d| match d { + $($value(child) if child.device_id == device_id => Some(child.device_id.clone()),)+ + _ => None, + }) + .next() + .ok_or_else(|| Error::DeviceNotFound)?, + HubDevice::ByNickname(nickname) => children + .iter() + .filter_map(|d| match d { + $($value(child) if child.nickname == nickname => Some(child.device_id.clone()),)+ + _ => None, + }) + .next() + .ok_or_else(|| Error::DeviceNotFound)?, + } + }}; +} + +/// Handler for the [H100](https://www.tapo.com/en/search/?q=H100) devices. +#[derive(Debug)] +pub struct HubHandler { + client: Arc>, +} + +/// Hub handler methods. +impl HubHandler { + pub(crate) fn new(client: Arc>) -> Self { + Self { client } + } + + /// Refreshes the authentication session. + pub async fn refresh_session(&mut self) -> Result<&mut Self, Error> { + self.client.write().await.refresh_session().await?; + Ok(self) + } + + /// Returns *device info* as [`DeviceInfoHubResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, try [`HubHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *child device list* as [`ChildDeviceHubResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API + /// or to support all the possible devices connected to the hub. + /// If the deserialization fails, or if a property that you care about it's not present, try [`HubHandler::get_child_device_list_json`]. + pub async fn get_child_device_list(&self) -> Result, Error> { + let mut results = Vec::new(); + let mut start_index = 0; + let mut fetch = true; + + while fetch { + let devices = self + .client + .read() + .await + .get_child_device_list::(start_index) + .await + .map(|r| r.devices)?; + + fetch = devices.len() == 10; + start_index += 10; + results.extend(devices); + } + + Ok(results) + } + + /// Returns *child device list* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + /// + /// # Arguments + /// + /// * `start_index` - the index to start fetching the child device list. + /// It should be `0` for the first page, `10` for the second, and so on. + pub async fn get_child_device_list_json( + &self, + start_index: u64, + ) -> Result { + self.client + .read() + .await + .get_child_device_list(start_index) + .await + } + + /// Returns *child device component list* as [`serde_json::Value`]. + /// This information is useful in debugging or when investigating new functionality to add. + pub async fn get_child_device_component_list_json(&self) -> Result { + self.client + .read() + .await + .get_child_device_component_list() + .await + } + + /// Returns a list of ringtones (alarm types) supported by the hub. + /// Used for debugging only. + pub async fn get_supported_ringtone_list(&self) -> Result, Error> { + self.client + .read() + .await + .get_supported_alarm_type_list() + .await + .map(|response| response.alarm_type_list) + } + + /// Start playing the hub alarm. + pub async fn play_alarm( + &self, + ringtone: AlarmRingtone, + volume: AlarmVolume, + duration: AlarmDuration, + ) -> Result<(), Error> { + self.client + .read() + .await + .play_alarm(PlayAlarmParams::new(ringtone, volume, duration)?) + .await + } + + /// Stop playing the hub alarm, if it's currently playing. + pub async fn stop_alarm(&self) -> Result<(), Error> { + self.client.read().await.stop_alarm().await + } +} + +/// Child device handler builders. +impl HubHandler { + /// Returns a [`KE100Handler`] for the given [`HubDevice`]. + /// + /// # Arguments + /// + /// * `identifier` - a hub device identifier + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::{ApiClient, HubDevice}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// // Connect to the hub + /// let hub = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .h100("192.168.1.100") + /// .await?; + /// // Get a handler for the child device + /// let device_id = "0000000000000000000000000000000000000000".to_string(); + /// let device = hub.ke100(HubDevice::ByDeviceId(device_id)).await?; + /// // Get the device info of the child device + /// let device_info = device.get_device_info().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn ke100(&self, identifier: HubDevice) -> Result { + let device_id = get_device_id!(self, identifier, ChildDeviceHubResult::KE100); + Ok(KE100Handler::new(self.client.clone(), device_id)) + } + + /// Returns a [`S200BHandler`] for the given [`HubDevice`]. + /// + /// # Arguments + /// + /// * `identifier` - a hub device identifier + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::{ApiClient, HubDevice}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// // Connect to the hub + /// let hub = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .h100("192.168.1.100") + /// .await?; + /// // Get a handler for the child device + /// let device_id = "0000000000000000000000000000000000000000".to_string(); + /// let device = hub.s200b(HubDevice::ByDeviceId(device_id)).await?; + /// // Get the device info of the child device + /// let device_info = device.get_device_info().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn s200b(&self, identifier: HubDevice) -> Result { + let device_id = get_device_id!(self, identifier, ChildDeviceHubResult::S200B); + Ok(S200BHandler::new(self.client.clone(), device_id)) + } + + /// Returns a [`T100Handler`] for the given [`HubDevice`]. + /// + /// # Arguments + /// + /// * `identifier` - a hub device identifier + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::{ApiClient, HubDevice}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// // Connect to the hub + /// let hub = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .h100("192.168.1.100") + /// .await?; + /// // Get a handler for the child device + /// let device_id = "0000000000000000000000000000000000000000".to_string(); + /// let device = hub.t100(HubDevice::ByDeviceId(device_id)).await?; + /// // Get the device info of the child device + /// let device_info = device.get_device_info().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn t100(&self, identifier: HubDevice) -> Result { + let device_id = get_device_id!(self, identifier, ChildDeviceHubResult::T100); + Ok(T100Handler::new(self.client.clone(), device_id)) + } + + /// Returns a [`T110Handler`] for the given [`HubDevice`]. + /// + /// # Arguments + /// + /// * `identifier` - a hub device identifier + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::{ApiClient, HubDevice}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// // Connect to the hub + /// let hub = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .h100("192.168.1.100") + /// .await?; + /// // Get a handler for the child device + /// let device_id = "0000000000000000000000000000000000000000".to_string(); + /// let device = hub.t110(HubDevice::ByDeviceId(device_id)).await?; + /// // Get the device info of the child device + /// let device_info = device.get_device_info().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn t110(&self, identifier: HubDevice) -> Result { + let device_id = get_device_id!(self, identifier, ChildDeviceHubResult::T110); + Ok(T110Handler::new(self.client.clone(), device_id)) + } + + /// Returns a [`T300Handler`] for the given [`HubDevice`]. + /// + /// # Arguments + /// + /// * `identifier` - a hub device identifier + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::{ApiClient, HubDevice}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// // Connect to the hub + /// let hub = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .h100("192.168.1.100") + /// .await?; + /// // Get a handler for the child device + /// let device_id = "0000000000000000000000000000000000000000".to_string(); + /// let device = hub.t300(HubDevice::ByDeviceId(device_id)).await?; + /// // Get the device info of the child device + /// let device_info = device.get_device_info().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn t300(&self, identifier: HubDevice) -> Result { + let device_id = get_device_id!(self, identifier, ChildDeviceHubResult::T300); + Ok(T300Handler::new(self.client.clone(), device_id)) + } + + /// Returns a [`T31XHandler`] for the given [`HubDevice`]. + /// + /// # Arguments + /// + /// * `identifier` - a hub device identifier + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::{ApiClient, HubDevice}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// // Connect to the hub + /// let hub = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .h100("192.168.1.100") + /// .await?; + /// // Get a handler for the child device + /// let device_id = "0000000000000000000000000000000000000000".to_string(); + /// let device = hub.t310(HubDevice::ByDeviceId(device_id)).await?; + /// // Get the device info of the child device + /// let device_info = device.get_device_info().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn t310(&self, identifier: HubDevice) -> Result { + let device_id = get_device_id!( + self, + identifier, + ChildDeviceHubResult::T310, + ChildDeviceHubResult::T315 + ); + Ok(T31XHandler::new(self.client.clone(), device_id)) + } + + /// Returns a [`T31XHandler`] for the given [`HubDevice`]. + /// + /// # Arguments + /// + /// * `identifier` - a hub device identifier + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::{ApiClient, HubDevice}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// // Connect to the hub + /// let hub = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .h100("192.168.1.100") + /// .await?; + /// // Get a handler for the child device + /// let device_id = "0000000000000000000000000000000000000000".to_string(); + /// let device = hub.t315(HubDevice::ByDeviceId(device_id)).await?; + /// // Get the device info of the child device + /// let device_info = device.get_device_info().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn t315(&self, identifier: HubDevice) -> Result { + self.t310(identifier).await + } +} + +#[async_trait] +impl HandlerExt for HubHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ) + } +} + +impl DeviceManagementExt for HubHandler {} + +/// Hub Device. +pub enum HubDevice { + /// By Device ID. + ByDeviceId(String), + /// By Nickname. + ByNickname(String), +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/light_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/light_handler.rs new file mode 100644 index 0000000..200083b --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/light_handler.rs @@ -0,0 +1,97 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::error::Error; +use crate::requests::LightSetDeviceInfoParams; +use crate::responses::{DeviceInfoLightResult, DeviceUsageEnergyMonitoringResult}; + +use super::{ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt}; + +/// Handler for the [L510](https://www.tapo.com/en/search/?q=L510), +/// [L520](https://www.tapo.com/en/search/?q=L520) and +/// [L610](https://www.tapo.com/en/search/?q=L610) devices. +#[derive(Debug)] +pub struct LightHandler { + client: Arc>, +} + +impl LightHandler { + pub(crate) fn new(client: Arc>) -> Self { + Self { client } + } + + /// Refreshes the authentication session. + pub async fn refresh_session(&mut self) -> Result<&mut Self, Error> { + self.client.write().await.refresh_session().await?; + Ok(self) + } + + /// Turns *on* the device. + pub async fn on(&self) -> Result<(), Error> { + let client = RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ); + + LightSetDeviceInfoParams::new(client).on().send().await + } + + /// Turns *off* the device. + pub async fn off(&self) -> Result<(), Error> { + let client = RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ); + + LightSetDeviceInfoParams::new(client).off().send().await + } + + /// Returns *device info* as [`DeviceInfoLightResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, try [`LightHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device usage* as [`DeviceUsageEnergyMonitoringResult`]. + pub async fn get_device_usage(&self) -> Result { + self.client.read().await.get_device_usage().await + } + + /// Sets the *brightness* and turns *on* the device. + /// + /// # Arguments + /// + /// * `brightness` - between 1 and 100 + pub async fn set_brightness(&self, brightness: u8) -> Result<(), Error> { + let client = RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ); + + LightSetDeviceInfoParams::new(client) + .brightness(brightness) + .send() + .await + } +} + +#[async_trait] +impl HandlerExt for LightHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ) + } +} + +impl DeviceManagementExt for LightHandler {} diff --git a/agents/tapo/tapo-fork/tapo/src/api/plug.rs b/agents/tapo/tapo-fork/tapo/src/api/plug.rs new file mode 100644 index 0000000..31192ee --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/plug.rs @@ -0,0 +1,9 @@ +/// Power strip plug. +pub enum Plug { + /// By Device ID. + ByDeviceId(String), + /// By Nickname. + ByNickname(String), + /// By Position. + ByPosition(u8), +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/plug_energy_monitoring_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/plug_energy_monitoring_handler.rs new file mode 100644 index 0000000..80f1c4d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/plug_energy_monitoring_handler.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::error::Error; +use crate::requests::{EnergyDataInterval, GenericSetDeviceInfoParams, PowerDataInterval}; +use crate::responses::{ + CountdownRulesResult, CurrentPowerResult, DeviceInfoPlugEnergyMonitoringResult, + DeviceUsageEnergyMonitoringResult, EnergyDataResult, EnergyUsageResult, NextEventResult, + PowerDataResult, ScheduleRulesResult, +}; + +use super::{ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt}; + +/// Handler for the [P110](https://www.tapo.com/en/search/?q=P110), +/// [P110M](https://www.tapo.com/en/search/?q=P110M) and +/// [P115](https://www.tapo.com/en/search/?q=P115) devices. +#[derive(Debug)] +pub struct PlugEnergyMonitoringHandler { + client: Arc>, +} + +impl PlugEnergyMonitoringHandler { + pub(crate) fn new(client: Arc>) -> Self { + Self { client } + } + + /// Refreshes the authentication session. + pub async fn refresh_session(&mut self) -> Result<&mut Self, Error> { + self.client.write().await.refresh_session().await?; + Ok(self) + } + + /// Turns *on* the device. + pub async fn on(&self) -> Result<(), Error> { + let json = serde_json::to_value(GenericSetDeviceInfoParams::device_on(true)?)?; + self.client.read().await.set_device_info(json).await + } + + /// Turns *off* the device. + pub async fn off(&self) -> Result<(), Error> { + let json = serde_json::to_value(GenericSetDeviceInfoParams::device_on(false)?)?; + self.client.read().await.set_device_info(json).await + } + + /// Returns *device info* as [`DeviceInfoPlugEnergyMonitoringResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, try [`PlugEnergyMonitoringHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *current power* as [`CurrentPowerResult`]. + pub async fn get_current_power(&self) -> Result { + self.client.read().await.get_current_power().await + } + + /// Returns *device usage* as [`DeviceUsageEnergyMonitoringResult`]. + pub async fn get_device_usage(&self) -> Result { + self.client.read().await.get_device_usage().await + } + + /// Returns *energy usage* as [`EnergyUsageResult`]. + pub async fn get_energy_usage(&self) -> Result { + self.client.read().await.get_energy_usage().await + } + + /// Returns *energy data* as [`EnergyDataResult`]. + pub async fn get_energy_data( + &self, + interval: EnergyDataInterval, + ) -> Result { + self.client.read().await.get_energy_data(interval).await + } + + /// Returns *power data* as [`PowerDataResult`]. + pub async fn get_power_data( + &self, + interval: PowerDataInterval, + ) -> Result { + self.client.read().await.get_power_data(interval).await + } + + /// Returns *countdown rules* as [`CountdownRulesResult`]. + pub async fn get_countdown_rules(&self) -> Result { + self.client.read().await.get_countdown_rules().await + } + + /// Returns *schedule rules* as [`ScheduleRulesResult`]. + pub async fn get_schedule_rules(&self) -> Result { + self.client.read().await.get_schedule_rules().await + } + + /// Returns *next scheduled event* as [`NextEventResult`]. + pub async fn get_next_event(&self) -> Result { + self.client.read().await.get_next_event().await + } +} + +#[async_trait] +impl HandlerExt for PlugEnergyMonitoringHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ) + } +} + +impl DeviceManagementExt for PlugEnergyMonitoringHandler {} diff --git a/agents/tapo/tapo-fork/tapo/src/api/plug_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/plug_handler.rs new file mode 100644 index 0000000..c6f3aa0 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/plug_handler.rs @@ -0,0 +1,71 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::error::Error; +use crate::requests::GenericSetDeviceInfoParams; +use crate::responses::{DeviceInfoPlugResult, DeviceUsageResult}; + +use super::{ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt}; + +/// Handler for the [P100](https://www.tapo.com/en/search/?q=P100) and +/// [P105](https://www.tapo.com/en/search/?q=P105) devices. +#[derive(Debug)] +pub struct PlugHandler { + client: Arc>, +} + +impl PlugHandler { + pub(crate) fn new(client: Arc>) -> Self { + Self { client } + } + + /// Refreshes the authentication session. + pub async fn refresh_session(&mut self) -> Result<&mut Self, Error> { + self.client.write().await.refresh_session().await?; + Ok(self) + } + + /// Turns *on* the device. + pub async fn on(&self) -> Result<(), Error> { + let json = serde_json::to_value(GenericSetDeviceInfoParams::device_on(true)?)?; + self.client.read().await.set_device_info(json).await + } + + /// Turns *off* the device. + pub async fn off(&self) -> Result<(), Error> { + let json = serde_json::to_value(GenericSetDeviceInfoParams::device_on(false)?)?; + self.client.read().await.set_device_info(json).await + } + + /// Returns *device info* as [`DeviceInfoPlugResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, try [`PlugHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device usage* as [`DeviceUsageResult`]. + pub async fn get_device_usage(&self) -> Result { + self.client.read().await.get_device_usage().await + } +} + +#[async_trait] +impl HandlerExt for PlugHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ) + } +} + +impl DeviceManagementExt for PlugHandler {} diff --git a/agents/tapo/tapo-fork/tapo/src/api/power_strip_energy_monitoring_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/power_strip_energy_monitoring_handler.rs new file mode 100644 index 0000000..386da50 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/power_strip_energy_monitoring_handler.rs @@ -0,0 +1,151 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::error::Error; +use crate::responses::{ + ChildDeviceListPowerStripEnergyMonitoringResult, DeviceInfoPowerStripResult, + PowerStripPlugEnergyMonitoringResult, +}; + +use super::{ + ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt, Plug, + PowerStripPlugEnergyMonitoringHandler, +}; + +/// Handler for the [P304M](https://www.tp-link.com/uk/search/?q=P304M) and +/// [P316M](https://www.tp-link.com/us/search/?q=P316M) devices. +#[derive(Debug)] +pub struct PowerStripEnergyMonitoringHandler { + client: Arc>, +} + +impl PowerStripEnergyMonitoringHandler { + pub(crate) fn new(client: Arc>) -> Self { + Self { client } + } + + /// Refreshes the authentication session. + pub async fn refresh_session(&mut self) -> Result<&mut Self, Error> { + self.client.write().await.refresh_session().await?; + Ok(self) + } + + /// Returns *device info* as [`DeviceInfoPowerStripResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, + /// try [`PowerStripEnergyMonitoringHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *child device list* as [`Vec`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, + /// try [`PowerStripEnergyMonitoringHandler::get_child_device_list_json`]. + pub async fn get_child_device_list( + &self, + ) -> Result, Error> { + self.client + .read() + .await + .get_child_device_list::(0) + .await + .map(|r| r.plugs) + } + + /// Returns *child device list* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_child_device_list_json(&self) -> Result { + self.client.read().await.get_child_device_list(0).await + } + + /// Returns *child device component list* as [`serde_json::Value`]. + /// This information is useful in debugging or when investigating new functionality to add. + pub async fn get_child_device_component_list_json(&self) -> Result { + self.client + .read() + .await + .get_child_device_component_list() + .await + } +} + +/// Child device handler builders. +impl PowerStripEnergyMonitoringHandler { + /// Returns a [`PowerStripPlugEnergyMonitoringHandler`] for the given [`Plug`]. + /// + /// # Arguments + /// + /// * `identifier` - a PowerStrip plug identifier. + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::{ApiClient, Plug}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// // Connect to the hub + /// let power_strip = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .p304("192.168.1.100") + /// .await?; + /// // Get a handler for the child device + /// let device_id = "0000000000000000000000000000000000000000".to_string(); + /// let device = power_strip.plug(Plug::ByDeviceId(device_id)).await?; + /// // Get the device info of the child device + /// let device_info = device.get_device_info().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn plug( + &self, + identifier: Plug, + ) -> Result { + let children = self.get_child_device_list().await?; + + let device_id = match identifier { + Plug::ByDeviceId(device_id) => children + .iter() + .find(|child| child.device_id == device_id) + .ok_or_else(|| Error::DeviceNotFound)? + .device_id + .clone(), + Plug::ByNickname(nickname) => children + .iter() + .find(|child| child.nickname == nickname) + .ok_or_else(|| Error::DeviceNotFound)? + .device_id + .clone(), + Plug::ByPosition(position) => children + .iter() + .find(|child| child.position == position) + .ok_or_else(|| Error::DeviceNotFound)? + .device_id + .clone(), + }; + + Ok(PowerStripPlugEnergyMonitoringHandler::new( + self.client.clone(), + device_id, + )) + } +} + +#[async_trait] +impl HandlerExt for PowerStripEnergyMonitoringHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ) + } +} + +impl DeviceManagementExt for PowerStripEnergyMonitoringHandler {} diff --git a/agents/tapo/tapo-fork/tapo/src/api/power_strip_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/power_strip_handler.rs new file mode 100644 index 0000000..a81353c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/power_strip_handler.rs @@ -0,0 +1,141 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::error::Error; +use crate::responses::{ + ChildDeviceListPowerStripResult, DeviceInfoPowerStripResult, PowerStripPlugResult, +}; + +use super::{ + ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt, Plug, PowerStripPlugHandler, +}; + +/// Handler for the [P300](https://www.tp-link.com/en/search/?q=P300) and +/// [P306](https://www.tp-link.com/us/search/?q=P306) devices. +#[derive(Debug)] +pub struct PowerStripHandler { + client: Arc>, +} + +impl PowerStripHandler { + pub(crate) fn new(client: Arc>) -> Self { + Self { client } + } + + /// Refreshes the authentication session. + pub async fn refresh_session(&mut self) -> Result<&mut Self, Error> { + self.client.write().await.refresh_session().await?; + Ok(self) + } + + /// Returns *device info* as [`DeviceInfoPowerStripResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, + /// try [`PowerStripHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *child device list* as [`Vec`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, + /// try [`PowerStripHandler::get_child_device_list_json`]. + pub async fn get_child_device_list(&self) -> Result, Error> { + self.client + .read() + .await + .get_child_device_list::(0) + .await + .map(|r| r.plugs) + } + + /// Returns *child device list* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_child_device_list_json(&self) -> Result { + self.client.read().await.get_child_device_list(0).await + } + + /// Returns *child device component list* as [`serde_json::Value`]. + /// This information is useful in debugging or when investigating new functionality to add. + pub async fn get_child_device_component_list_json(&self) -> Result { + self.client + .read() + .await + .get_child_device_component_list() + .await + } +} + +/// Child device handler builders. +impl PowerStripHandler { + /// Returns a [`PowerStripPlugHandler`] for the given [`Plug`]. + /// + /// # Arguments + /// + /// * `identifier` - a PowerStrip plug identifier. + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::{ApiClient, Plug}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// // Connect to the hub + /// let power_strip = ApiClient::new("tapo-username@example.com", "tapo-password") + /// .p300("192.168.1.100") + /// .await?; + /// // Get a handler for the child device + /// let device_id = "0000000000000000000000000000000000000000".to_string(); + /// let device = power_strip.plug(Plug::ByDeviceId(device_id)).await?; + /// // Get the device info of the child device + /// let device_info = device.get_device_info().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn plug(&self, identifier: Plug) -> Result { + let children = self.get_child_device_list().await?; + + let device_id = match identifier { + Plug::ByDeviceId(device_id) => children + .iter() + .find(|child| child.device_id == device_id) + .ok_or_else(|| Error::DeviceNotFound)? + .device_id + .clone(), + Plug::ByNickname(nickname) => children + .iter() + .find(|child| child.nickname == nickname) + .ok_or_else(|| Error::DeviceNotFound)? + .device_id + .clone(), + Plug::ByPosition(position) => children + .iter() + .find(|child| child.position == position) + .ok_or_else(|| Error::DeviceNotFound)? + .device_id + .clone(), + }; + + Ok(PowerStripPlugHandler::new(self.client.clone(), device_id)) + } +} + +#[async_trait] +impl HandlerExt for PowerStripHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ) + } +} + +impl DeviceManagementExt for PowerStripHandler {} diff --git a/agents/tapo/tapo-fork/tapo/src/api/protocol.rs b/agents/tapo/tapo-fork/tapo/src/api/protocol.rs new file mode 100644 index 0000000..ab44cce --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/protocol.rs @@ -0,0 +1,8 @@ +mod discovery_protocol; +mod klap_cipher; +mod klap_protocol; +mod passthrough_cipher; +mod passthrough_protocol; +mod tapo_protocol; + +pub(crate) use tapo_protocol::*; diff --git a/agents/tapo/tapo-fork/tapo/src/api/protocol/discovery_protocol.rs b/agents/tapo/tapo-fork/tapo/src/api/protocol/discovery_protocol.rs new file mode 100644 index 0000000..1d50593 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/protocol/discovery_protocol.rs @@ -0,0 +1,64 @@ +use log::debug; +use reqwest::Client; + +use crate::api::protocol::klap_protocol::KlapProtocol; +use crate::requests::{EmptyParams, TapoParams, TapoRequest}; +use crate::responses::{TapoResponse, validate_response}; +use crate::{Error, TapoResponseError}; + +use super::{TapoProtocolType, passthrough_protocol::PassthroughProtocol}; + +#[derive(Debug, Clone)] +pub(crate) struct DiscoveryProtocol { + client: Client, +} + +impl DiscoveryProtocol { + pub fn new(client: Client) -> Self { + Self { client } + } + + pub async fn discover(&mut self, url: &str) -> Result { + debug!("Testing the Passthrough protocol..."); + if self.is_passthrough_supported(url).await? { + debug!("Supported. Setting up the Passthrough protocol..."); + Ok(TapoProtocolType::Passthrough(PassthroughProtocol::new( + self.client.clone(), + )?)) + } else { + debug!("Not supported. Setting up the Klap protocol..."); + Ok(TapoProtocolType::Klap(KlapProtocol::new( + self.client.clone(), + ))) + } + } + + async fn is_passthrough_supported(&self, url: &str) -> Result { + match self.test_passthrough(url).await { + Err(Error::Tapo(TapoResponseError::Unknown(code))) => Ok(code != 1003), + Err(err) => Err(err), + Ok(_) => Ok(true), + } + } + + async fn test_passthrough(&self, url: &str) -> Result<(), Error> { + let request = TapoRequest::ComponentNegotiation(TapoParams::new(EmptyParams)); + let request_string = serde_json::to_string(&request)?; + debug!("Component negotiation request: {request_string}"); + + let response = self + .client + .post(url) + .body(request_string) + .send() + .await? + .json::>() + .await?; + + debug!("Device responded with: {response:?}"); + + validate_response(&response)?; + + Ok(()) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/protocol/klap_cipher.rs b/agents/tapo/tapo-fork/tapo/src/api/protocol/klap_cipher.rs new file mode 100644 index 0000000..330c825 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/protocol/klap_cipher.rs @@ -0,0 +1,112 @@ +use std::sync::atomic::{AtomicI32, Ordering}; + +use aes::Aes128; +use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit, block_padding}; +use cbc::{Decryptor, Encryptor}; + +#[derive(Debug)] +pub(super) struct KlapCipher { + key: Vec, + iv: Vec, + seq: AtomicI32, + sig: Vec, +} + +impl KlapCipher { + pub fn new( + local_seed: Vec, + remote_seed: Vec, + user_hash: Vec, + ) -> anyhow::Result { + let local_hash = &[local_seed, remote_seed, user_hash].concat(); + + let (iv, seq) = Self::iv_derive(local_hash)?; + + Ok(Self { + key: Self::key_derive(local_hash), + iv, + seq: AtomicI32::new(seq), + sig: Self::sig_derive(local_hash), + }) + } + + pub fn encrypt(&self, data: String) -> anyhow::Result<(Vec, i32)> { + self.seq.fetch_add(1, Ordering::Relaxed); + let seq = self.seq.load(Ordering::Relaxed); + let encryptor = Encryptor::::new_from_slices(&self.key, &self.iv_seq(seq))?; + + let cipher_bytes = + encryptor.encrypt_padded_vec_mut::(data.as_bytes()); + + let signature = Self::sha256( + &[ + self.sig.as_slice(), + &seq.to_be_bytes(), + cipher_bytes.as_slice(), + ] + .concat(), + ); + + let result = [&signature, cipher_bytes.as_slice()].concat(); + + Ok((result, seq)) + } + + pub fn decrypt(&self, seq: i32, cipher_bytes: Vec) -> anyhow::Result { + let decryptor = Decryptor::::new_from_slices(&self.key, &self.iv_seq(seq))?; + + let decrypted_bytes = decryptor + .decrypt_padded_vec_mut::(&cipher_bytes[32..]) + .map_err(|e| anyhow::anyhow!("Decryption error: {:?}", e))?; + let decrypted = std::str::from_utf8(&decrypted_bytes)?.to_string(); + + Ok(decrypted) + } +} + +impl KlapCipher { + fn key_derive(local_hash: &[u8]) -> Vec { + let local_hash = &["lsk".as_bytes(), local_hash].concat(); + let hash = Self::sha256(local_hash); + let key = &hash[..16]; + key.to_vec() + } + + fn iv_derive(local_hash: &[u8]) -> anyhow::Result<(Vec, i32)> { + let local_hash = &["iv".as_bytes(), local_hash].concat(); + let hash = Self::sha256(local_hash); + let iv = &hash[..12]; + let seq: [u8; 4] = hash[hash.len() - 4..].try_into()?; + let seq = i32::from_be_bytes(seq); + Ok((iv.to_vec(), seq)) + } + + fn sig_derive(local_hash: &[u8]) -> Vec { + let local_hash = &["ldk".as_bytes(), local_hash].concat(); + let hash = Self::sha256(local_hash); + let key = &hash[..28]; + key.to_vec() + } + + fn iv_seq(&self, seq: i32) -> Vec { + let mut iv_seq = self.iv.clone(); + iv_seq.extend_from_slice(&seq.to_be_bytes()); + iv_seq + } +} + +impl KlapCipher { + pub fn sha1(value: &[u8]) -> [u8; 20] { + use sha1::{Digest, Sha1}; + let mut hasher = Sha1::new(); + hasher.update(value); + hasher.finalize().into() + } + + pub fn sha256(value: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(value); + hasher.finalize().into() + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/protocol/klap_protocol.rs b/agents/tapo/tapo-fork/tapo/src/api/protocol/klap_protocol.rs new file mode 100644 index 0000000..a5455ad --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/protocol/klap_protocol.rs @@ -0,0 +1,230 @@ +use std::fmt; + +use async_trait::async_trait; +use log::{debug, error, trace}; +use reqwest::header::COOKIE; +use reqwest::{Client, StatusCode}; +use rsa::rand_core::{OsRng, RngCore as _}; +use serde::de::DeserializeOwned; + +use crate::api::protocol::TapoProtocol; +use crate::requests::TapoRequest; +use crate::responses::{TapoResponse, TapoResponseExt, validate_response}; +use crate::{Error, TapoResponseError}; + +use super::TapoProtocolExt; +use super::discovery_protocol::DiscoveryProtocol; +use super::klap_cipher::KlapCipher; + +#[derive(Debug)] +pub(crate) struct KlapProtocol { + client: Client, + cookie: String, + rng: OsRng, + url: Option, + cipher: Option, +} + +#[async_trait] +impl TapoProtocolExt for KlapProtocol { + async fn login( + &mut self, + url: String, + username: String, + password: String, + ) -> Result<(), Error> { + self.handshake(url, username, password).await?; + Ok(()) + } + + async fn refresh_session(&mut self, username: String, password: String) -> Result<(), Error> { + let url = self.url.as_ref().expect("This should never happen").clone(); + self.handshake(url, username, password).await?; + Ok(()) + } + + async fn execute_request( + &self, + request: TapoRequest, + _with_token: bool, + ) -> Result, Error> + where + R: fmt::Debug + DeserializeOwned + TapoResponseExt, + { + let url = self.url.as_ref().expect("This should never happen"); + let cipher = self.get_cipher_ref(); + + let request_string = serde_json::to_string(&request)?; + debug!("Request: {request_string}"); + + let (payload, seq) = cipher.encrypt(request_string)?; + + let response = self + .client + .post(format!("{url}/request?seq={seq}")) + .header(COOKIE, self.cookie.clone()) + .body(payload) + .send() + .await?; + + if !response.status().is_success() { + error!("Response error: {}", response.status()); + + let error = match response.status() { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + TapoResponseError::SessionTimeout + } + _ => TapoResponseError::InvalidResponse, + }; + + return Err(Error::Tapo(error)); + } + + let response_body = response.bytes().await.map_err(anyhow::Error::from)?; + + let response_decrypted = cipher.decrypt(seq, response_body.to_vec())?; + trace!("Device responded with (raw): {response_decrypted}"); + + let response: TapoResponse = serde_json::from_str(&response_decrypted)?; + debug!("Device responded with: {response:?}"); + + validate_response(&response)?; + let result = response.result; + + Ok(result) + } + + fn clone_as_discovery(&self) -> DiscoveryProtocol { + DiscoveryProtocol::new(self.client.clone()) + } +} + +impl KlapProtocol { + pub fn new(client: Client) -> Self { + Self { + client, + cookie: String::new(), + rng: OsRng, + url: None, + cipher: None, + } + } + + async fn handshake( + &mut self, + url: String, + username: String, + password: String, + ) -> Result<(), Error> { + let auth_hash = KlapCipher::sha256( + &[ + KlapCipher::sha1(username.as_bytes()), + KlapCipher::sha1(password.as_bytes()), + ] + .concat(), + ) + .to_vec(); + + let local_seed = self.get_local_seed().to_vec(); + let remote_seed = self.handshake1(&url, &local_seed, &auth_hash).await?; + + self.handshake2(&url, &local_seed, &remote_seed, &auth_hash) + .await?; + + let cipher = KlapCipher::new(local_seed, remote_seed, auth_hash)?; + + self.url.replace(url); + self.cipher.replace(cipher); + + Ok(()) + } + + async fn handshake1( + &mut self, + url: &str, + local_seed: &[u8], + auth_hash: &[u8], + ) -> Result, Error> { + debug!("Performing handshake1..."); + let url = format!("{url}/handshake1"); + + let response = self + .client + .post(&url) + .body(local_seed.to_vec()) + .send() + .await?; + + if !response.status().is_success() { + error!("Handshake1 error: {}", response.status()); + + if response.status() == StatusCode::FORBIDDEN { + return Err(Error::Tapo(TapoResponseError::Forbidden { + code: "FORBIDDEN".to_string(), + description: r"Make sure Third-Party Compatibility is turned on in the Tapo app. If it's already enabled, try switching it off and then back on again. You can find this option by navigating to Me > Third-Party Services in the app." + .to_string(), + })); + } + return Err(Error::Tapo(TapoResponseError::InvalidResponse)); + } + + self.cookie = TapoProtocol::get_cookie(response.cookies())?; + + let response_body = response.bytes().await.map_err(anyhow::Error::from)?; + + let (remote_seed, server_hash) = response_body.split_at(16); + let local_hash = KlapCipher::sha256(&[local_seed, remote_seed, auth_hash].concat()); + + if local_hash != server_hash { + error!("Local hash does not match server hash"); + return Err(Error::Tapo(TapoResponseError::Unauthorized { + code: "HASH_MISMATCH".to_string(), + description: "The device response did not match the challenge issued by the library. Make sure that your email and password are correct -— both are case-sensitive. Before adding a new device, disconnect any existing TP-Link/Tapo devices on the network. The TP-Link Simple Setup (TSS) protocol, which shares credentials from previously configured devices, may interfere with authentication. If the problem continues, perform a factory reset on the new device and add it again with no other TP-Link devices active during setup.".to_string(), + })); + } + + debug!("Handshake1 OK"); + + Ok(remote_seed.to_vec()) + } + + async fn handshake2( + &self, + url: &str, + local_seed: &[u8], + remote_seed: &[u8], + auth_hash: &[u8], + ) -> Result<(), Error> { + debug!("Performing handshake2..."); + let url = format!("{url}/handshake2"); + + let payload = KlapCipher::sha256(&[remote_seed, local_seed, auth_hash].concat()); + + let response = self + .client + .post(&url) + .header(COOKIE, self.cookie.clone()) + .body(payload.to_vec()) + .send() + .await?; + + if !response.status().is_success() { + error!("Handshake2 error: {}", response.status()); + return Err(Error::Tapo(TapoResponseError::InvalidResponse)); + } + + debug!("Handshake2 OK"); + + Ok(()) + } + + fn get_local_seed(&mut self) -> [u8; 16] { + let mut buffer = [0u8; 16]; + self.rng.fill_bytes(&mut buffer); + buffer + } + + fn get_cipher_ref(&self) -> &KlapCipher { + self.cipher.as_ref().expect("This should never happen") + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/protocol/passthrough_cipher.rs b/agents/tapo/tapo-fork/tapo/src/api/protocol/passthrough_cipher.rs new file mode 100644 index 0000000..04e4c3a --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/protocol/passthrough_cipher.rs @@ -0,0 +1,152 @@ +use aes::Aes128; +use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit, block_padding}; +use base64::{Engine as _, engine::general_purpose}; +use cbc::{Decryptor, Encryptor}; +use log::debug; +use rsa::pkcs8::{EncodePublicKey, LineEnding}; +use rsa::rand_core::CryptoRngCore; +use rsa::{Pkcs1v15Encrypt, RsaPrivateKey}; +use sha1::{Digest, Sha1}; + +#[derive(Debug, Clone)] +pub(crate) struct PassthroughKeyPair { + rsa: RsaPrivateKey, +} + +impl PassthroughKeyPair { + pub fn new(mut rng: R) -> anyhow::Result + where + R: CryptoRngCore, + { + debug!("Generating RSA key pair..."); + let rsa = RsaPrivateKey::new(&mut rng, 1024)?; + + Ok(Self { rsa }) + } + + pub fn get_public_key(&self) -> anyhow::Result { + let public_key = + rsa::RsaPublicKey::to_public_key_pem(&self.rsa.to_public_key(), LineEnding::LF)?; + + Ok(public_key) + } +} + +#[derive(Debug)] +pub(crate) struct PassthroughCipher { + key: Vec, + iv: Vec, +} + +impl PassthroughCipher { + pub fn new(key: &str, key_pair: &PassthroughKeyPair) -> anyhow::Result { + debug!("Will decode handshake key {:?}...", &key[..5]); + + let key_bytes = general_purpose::STANDARD.decode(key)?; + let buf = key_pair.rsa.decrypt(Pkcs1v15Encrypt, &key_bytes)?; + + if buf.len() != 32 { + return Err(anyhow::anyhow!("Expected 32 bytes, got {}", buf.len())); + } + + Ok(PassthroughCipher { + key: buf[0..16].to_vec(), + iv: buf[16..32].to_vec(), + }) + } + + pub fn encrypt(&self, data: &str) -> anyhow::Result { + let encryptor = Encryptor::::new_from_slices(&self.key, &self.iv)?; + + let cipher_bytes = + encryptor.encrypt_padded_vec_mut::(data.as_bytes()); + let cipher_base64 = general_purpose::STANDARD.encode(cipher_bytes); + + Ok(cipher_base64) + } + + pub fn decrypt(&self, cipher_base64: &str) -> anyhow::Result { + let decryptor = Decryptor::::new_from_slices(&self.key, &self.iv)?; + + let cipher_bytes = general_purpose::STANDARD.decode(cipher_base64)?; + let decrypted_bytes = decryptor + .decrypt_padded_vec_mut::(&cipher_bytes) + .map_err(|e| anyhow::anyhow!("Decryption error: {:?}", e))?; + + let decrypted = std::str::from_utf8(&decrypted_bytes)?.to_string(); + + Ok(decrypted) + } +} + +impl PassthroughCipher { + pub fn sha1_digest_username(username: String) -> String { + let mut hasher = Sha1::new(); + hasher.update(username.as_bytes()); + let hash = hasher.finalize(); + + base16ct::lower::encode_string(&hash) + } +} + +#[cfg(test)] +mod tests { + use rand::{Rng, SeedableRng, rngs::StdRng}; + + use super::*; + + #[test] + fn test_passthrough_cipher() -> anyhow::Result<()> { + let mut rng = StdRng::seed_from_u64(0); + + let key_pair = PassthroughKeyPair::new(&mut rng)?; + + let public_key = key_pair.get_public_key()?; + assert_eq!(public_key.len(), 272); + assert_eq!( + public_key, + "-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDimR6OafJxMw3NNPTE8fTGA+I5 +Djrk5YCtTAcnGXPsWP8tgGfgJ/S5SI21CzMiNA0GrbA1a2fIYafR73a5Be3+DTWd +pg/BjhASlKZos6CbkkVsOMeVKQOkdToGrRtHW6cIofLM6ZvZvuzVTTPdMd+paEjq +waihnXBCkPwQndikfwIDAQAB +-----END PUBLIC KEY-----\n" + ); + + let key_bytes = vec![rng.r#gen(); 32]; + let key_encrypted_bytes = + key_pair + .rsa + .to_public_key() + .encrypt(&mut rng, Pkcs1v15Encrypt, &key_bytes)?; + + assert_eq!(key_encrypted_bytes.len(), 128); + assert_eq!( + key_encrypted_bytes, + vec![ + 166, 230, 248, 62, 63, 183, 64, 126, 54, 24, 66, 139, 130, 63, 33, 84, 139, 98, 55, + 39, 200, 61, 91, 180, 108, 199, 245, 34, 183, 145, 198, 211, 79, 76, 151, 50, 16, + 136, 184, 88, 9, 118, 167, 70, 106, 212, 38, 17, 146, 140, 177, 42, 146, 149, 70, + 129, 229, 16, 56, 138, 206, 24, 168, 167, 65, 225, 136, 188, 137, 208, 216, 31, 44, + 195, 218, 150, 61, 172, 36, 63, 66, 56, 144, 5, 80, 199, 223, 153, 201, 237, 187, + 243, 81, 255, 139, 78, 27, 126, 49, 186, 218, 135, 98, 88, 250, 254, 135, 155, 196, + 101, 62, 234, 63, 254, 184, 34, 195, 110, 70, 213, 237, 228, 199, 37, 101, 3, 33, + 157 + ] + ); + + let key = general_purpose::STANDARD.encode(key_encrypted_bytes); + + let cipher = PassthroughCipher::new(&key, &key_pair)?; + + let message = "hello"; + let message_encrypted = cipher.encrypt(message)?; + + assert_eq!(message_encrypted.len(), 24); + assert_eq!(message_encrypted, "qUAXr1/bAt6+pHYjns76KA=="); + + assert_eq!(cipher.decrypt(&message_encrypted)?, message); + + Ok(()) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/protocol/passthrough_protocol.rs b/agents/tapo/tapo-fork/tapo/src/api/protocol/passthrough_protocol.rs new file mode 100644 index 0000000..71bee93 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/protocol/passthrough_protocol.rs @@ -0,0 +1,204 @@ +use std::fmt; + +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose}; +use log::{debug, trace}; +use reqwest::Client; +use reqwest::header::COOKIE; +use rsa::rand_core::OsRng; +use serde::de::DeserializeOwned; + +use crate::api::protocol::TapoProtocol; +use crate::requests::{ + HandshakeParams, LoginDeviceParams, SecurePassthroughParams, TapoParams, TapoRequest, +}; +use crate::responses::{ + HandshakeResult, TapoResponse, TapoResponseExt, TapoResult, TokenResult, validate_response, +}; + +use crate::{Error, TapoResponseError}; + +use super::discovery_protocol::DiscoveryProtocol; +use super::passthrough_cipher::{PassthroughCipher, PassthroughKeyPair}; +use super::tapo_protocol::TapoProtocolExt; + +#[derive(Debug)] +pub(crate) struct PassthroughProtocol { + client: Client, + key_pair: PassthroughKeyPair, + session: Option, +} + +#[derive(Debug)] +struct Session { + pub url: String, + pub cookie: String, + pub cipher: PassthroughCipher, + pub token: Option, +} + +#[async_trait] +impl TapoProtocolExt for PassthroughProtocol { + async fn login( + &mut self, + url: String, + username: String, + password: String, + ) -> Result<(), Error> { + self.handshake(url).await?; + self.login_request(username, password).await?; + + Ok(()) + } + + async fn refresh_session(&mut self, username: String, password: String) -> Result<(), Error> { + let url = self.get_session_ref().url.clone(); + self.login(url, username, password).await + } + + async fn execute_request( + &self, + request: TapoRequest, + with_token: bool, + ) -> Result, Error> + where + R: fmt::Debug + DeserializeOwned + TapoResponseExt, + { + let session = self.get_session_ref(); + let url = if with_token { + format!( + "{}?token={}", + &session.url, + session + .token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Token should not be None"))? + ) + } else { + session.url.clone() + }; + + let request_string = serde_json::to_string(&request)?; + debug!("Request to passthrough: {request_string}"); + + let request_encrypted = session.cipher.encrypt(&request_string)?; + + let secure_passthrough_params = SecurePassthroughParams::new(&request_encrypted); + let secure_passthrough_request = + TapoRequest::SecurePassthrough(TapoParams::new(secure_passthrough_params)); + let secure_passthrough_request_string = serde_json::to_string(&secure_passthrough_request)?; + + let request = self + .client + .post(url) + .header(COOKIE, session.cookie.clone()) + .body(secure_passthrough_request_string); + + let response = request + .send() + .await? + .json::>() + .await?; + + debug!("Device responded with: {response:?}"); + + validate_response(&response)?; + + let inner_response_encrypted = response + .result + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))? + .response; + + let inner_response_decrypted = session.cipher.decrypt(&inner_response_encrypted)?; + + trace!("Device inner response (raw): {inner_response_decrypted}"); + + let inner_response: TapoResponse = serde_json::from_str(&inner_response_decrypted)?; + + debug!("Device inner response: {inner_response:?}"); + + validate_response(&inner_response)?; + + let result = inner_response.result; + + Ok(result) + } + + fn clone_as_discovery(&self) -> DiscoveryProtocol { + DiscoveryProtocol::new(self.client.clone()) + } +} + +impl PassthroughProtocol { + pub fn new(client: Client) -> Result { + Ok(Self { + client, + key_pair: PassthroughKeyPair::new(OsRng)?, + session: None, + }) + } + + async fn handshake(&mut self, url: String) -> Result<(), Error> { + debug!("Performing handshake..."); + + let params = HandshakeParams::new(self.key_pair.get_public_key()?); + let request = TapoRequest::Handshake(TapoParams::new(params)); + let request_string = serde_json::to_string(&request)?; + + let response = self.client.post(&url).body(request_string).send().await?; + let cookie = TapoProtocol::get_cookie(response.cookies())?; + let response_json = response.json::>().await?; + + validate_response(&response_json)?; + + let handshake_key = response_json + .result + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))? + .key; + + debug!("Handshake OK"); + + let cipher = PassthroughCipher::new(&handshake_key, &self.key_pair)?; + + self.session.replace(Session { + url, + cookie, + cipher, + token: None, + }); + + Ok(()) + } + + async fn login_request(&mut self, username: String, password: String) -> Result<(), Error> { + let username_digest = PassthroughCipher::sha1_digest_username(username); + debug!("Username digest: {username_digest}"); + + let username = general_purpose::STANDARD.encode(username_digest); + let password = general_purpose::STANDARD.encode(password); + + debug!("Will login with username '{username}'..."); + + let params = TapoParams::new(LoginDeviceParams::new(&username, &password)) + .set_request_time_mils()?; + let request = TapoRequest::LoginDevice(params); + + let result = self + .execute_request::(request, false) + .await? + .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))?; + + let session = self.get_session_mut(); + session.token.replace(result.token); + + Ok(()) + } + + fn get_session_ref(&self) -> &Session { + self.session.as_ref().expect("This should never happen") + } + + fn get_session_mut(&mut self) -> &mut Session { + self.session.as_mut().expect("This should never happen") + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/protocol/tapo_protocol.rs b/agents/tapo/tapo-fork/tapo/src/api/protocol/tapo_protocol.rs new file mode 100644 index 0000000..2a04347 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/protocol/tapo_protocol.rs @@ -0,0 +1,127 @@ +use std::fmt; + +use async_trait::async_trait; +use reqwest::Client; +use reqwest::cookie::Cookie; +use serde::de::DeserializeOwned; + +use crate::Error; +use crate::responses::TapoResponseExt; +use crate::{TapoResponseError, requests::TapoRequest}; + +use super::{ + discovery_protocol::DiscoveryProtocol, klap_protocol::KlapProtocol, + passthrough_protocol::PassthroughProtocol, +}; + +#[derive(Debug, Clone)] +pub(crate) struct TapoProtocol { + protocol: TapoProtocolType, +} + +#[async_trait] +pub(crate) trait TapoProtocolExt { + async fn login(&mut self, url: String, username: String, password: String) + -> Result<(), Error>; + async fn refresh_session(&mut self, username: String, password: String) -> Result<(), Error>; + async fn execute_request( + &self, + request: TapoRequest, + with_token: bool, + ) -> Result, Error> + where + R: fmt::Debug + DeserializeOwned + TapoResponseExt; + fn clone_as_discovery(&self) -> DiscoveryProtocol; +} + +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub(crate) enum TapoProtocolType { + Discovery(DiscoveryProtocol), + Passthrough(PassthroughProtocol), + Klap(KlapProtocol), +} + +impl Clone for TapoProtocolType { + fn clone(&self) -> Self { + match self { + Self::Discovery(protocol) => Self::Discovery(protocol.clone()), + Self::Passthrough(protocol) => Self::Discovery(protocol.clone_as_discovery()), + Self::Klap(protocol) => Self::Discovery(protocol.clone_as_discovery()), + } + } +} + +#[async_trait] +impl TapoProtocolExt for TapoProtocol { + async fn login( + &mut self, + url: String, + username: String, + password: String, + ) -> Result<(), Error> { + if let TapoProtocolType::Discovery(protocol) = &mut self.protocol { + self.protocol = protocol.discover(&url).await?; + } + + match &mut self.protocol { + TapoProtocolType::Passthrough(protocol) => { + protocol.login(url, username, password).await + } + TapoProtocolType::Klap(protocol) => protocol.login(url, username, password).await, + _ => Err(anyhow::anyhow!("The protocol discovery should have happened already").into()), + } + } + + async fn refresh_session(&mut self, username: String, password: String) -> Result<(), Error> { + match &mut self.protocol { + TapoProtocolType::Passthrough(protocol) => { + protocol.refresh_session(username, password).await + } + TapoProtocolType::Klap(protocol) => protocol.refresh_session(username, password).await, + _ => Err(anyhow::anyhow!("The protocol discovery should have happened already").into()), + } + } + + async fn execute_request( + &self, + request: TapoRequest, + with_token: bool, + ) -> Result, Error> + where + R: fmt::Debug + DeserializeOwned + TapoResponseExt, + { + match &self.protocol { + TapoProtocolType::Passthrough(protocol) => { + protocol.execute_request(request, with_token).await + } + TapoProtocolType::Klap(protocol) => protocol.execute_request(request, with_token).await, + _ => Err(anyhow::anyhow!("The protocol discovery should have happened already").into()), + } + } + + fn clone_as_discovery(&self) -> DiscoveryProtocol { + match &self.protocol { + TapoProtocolType::Discovery(protocol) => protocol.clone(), + TapoProtocolType::Passthrough(protocol) => protocol.clone_as_discovery(), + TapoProtocolType::Klap(protocol) => protocol.clone_as_discovery(), + } + } +} + +impl TapoProtocol { + pub fn new(client: Client) -> Self { + Self { + protocol: TapoProtocolType::Discovery(DiscoveryProtocol::new(client)), + } + } + + pub fn get_cookie<'a>(mut cookies: impl Iterator>) -> Result { + let cookie = cookies.find(|c| c.name() == "TP_SESSIONID"); + + match cookie { + Some(cookie) => Ok(format!("{}={}", cookie.name(), cookie.value())), + None => Err(Error::Tapo(TapoResponseError::InvalidResponse)), + } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/api/rgb_light_strip_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/rgb_light_strip_handler.rs new file mode 100644 index 0000000..b7e788a --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/rgb_light_strip_handler.rs @@ -0,0 +1,143 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::error::Error; +use crate::requests::{Color, ColorLightSetDeviceInfoParams}; +use crate::responses::{DeviceInfoRgbLightStripResult, DeviceUsageEnergyMonitoringResult}; + +use super::{ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt}; + +/// Handler for the [L900](https://www.tapo.com/en/search/?q=L900) devices. +#[derive(Debug)] +pub struct RgbLightStripHandler { + client: Arc>, +} + +impl RgbLightStripHandler { + pub(crate) fn new(client: Arc>) -> Self { + Self { client } + } + + /// Refreshes the authentication session. + pub async fn refresh_session(&mut self) -> Result<&mut Self, Error> { + self.client.write().await.refresh_session().await?; + Ok(self) + } + + /// Turns *on* the device. + pub async fn on(&self) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new().on().send(self).await + } + + /// Turns *off* the device. + pub async fn off(&self) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new().off().send(self).await + } + + /// Returns *device info* as [`DeviceInfoRgbLightStripResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, try [`RgbLightStripHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device usage* as [`DeviceUsageEnergyMonitoringResult`]. + pub async fn get_device_usage(&self) -> Result { + self.client.read().await.get_device_usage().await + } + + /// Returns a [`ColorLightSetDeviceInfoParams`] builder that allows multiple properties to be set in a single request. + /// [`ColorLightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # use tapo::requests::Color; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// # .l900("192.168.1.100") + /// # .await?; + /// device + /// .set() + /// .brightness(50) + /// .color(Color::HotPink) + /// .send(&device) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn set(&self) -> ColorLightSetDeviceInfoParams { + ColorLightSetDeviceInfoParams::new() + } + + /// Sets the *brightness* and turns *on* the device. + /// + /// # Arguments + /// + /// * `brightness` - between 1 and 100 + pub async fn set_brightness(&self, brightness: u8) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .brightness(brightness) + .send(self) + .await + } + + /// Sets the *color* and turns *on* the device. + /// + /// # Arguments + /// + /// * `color` - one of [crate::requests::Color] as defined in the Google Home app + pub async fn set_color(&self, color: Color) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .color(color) + .send(self) + .await + } + + /// Sets the *hue*, *saturation* and turns *on* the device. + /// + /// # Arguments + /// + /// * `hue` - between 0 and 360 + /// * `saturation` - between 1 and 100 + pub async fn set_hue_saturation(&self, hue: u16, saturation: u8) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .hue_saturation(hue, saturation) + .send(self) + .await + } + + /// Sets the *color temperature* and turns *on* the device. + /// + /// # Arguments + /// + /// * `color_temperature` - between 2500 and 6500 + pub async fn set_color_temperature(&self, color_temperature: u16) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .color_temperature(color_temperature) + .send(self) + .await + } +} + +#[async_trait] +impl HandlerExt for RgbLightStripHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ) + } +} + +impl DeviceManagementExt for RgbLightStripHandler {} diff --git a/agents/tapo/tapo-fork/tapo/src/api/rgbic_light_strip_handler.rs b/agents/tapo/tapo-fork/tapo/src/api/rgbic_light_strip_handler.rs new file mode 100644 index 0000000..500652c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/api/rgbic_light_strip_handler.rs @@ -0,0 +1,165 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{RwLock, RwLockReadGuard}; + +use crate::error::Error; +use crate::requests::{Color, ColorLightSetDeviceInfoParams, LightingEffect}; +use crate::responses::{DeviceInfoRgbicLightStripResult, DeviceUsageEnergyMonitoringResult}; + +use super::{ApiClient, ApiClientExt, DeviceManagementExt, HandlerExt}; + +/// Handler for the [L920](https://www.tapo.com/en/search/?q=L920) and +/// [L930](https://www.tapo.com/en/search/?q=L930) devices. +#[derive(Debug)] +pub struct RgbicLightStripHandler { + client: Arc>, +} + +impl RgbicLightStripHandler { + pub(crate) fn new(client: Arc>) -> Self { + Self { client } + } + + /// Refreshes the authentication session. + pub async fn refresh_session(&mut self) -> Result<&mut Self, Error> { + self.client.write().await.refresh_session().await?; + Ok(self) + } + + /// Turns *on* the device. + pub async fn on(&self) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new().on().send(self).await + } + + /// Turns *off* the device. + pub async fn off(&self) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new().off().send(self).await + } + + /// Returns *device info* as [`DeviceInfoRgbicLightStripResult`]. + /// It is not guaranteed to contain all the properties returned from the Tapo API. + /// If the deserialization fails, or if a property that you care about it's not present, try [`RgbicLightStripHandler::get_device_info_json`]. + pub async fn get_device_info(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device info* as [`serde_json::Value`]. + /// It contains all the properties returned from the Tapo API. + pub async fn get_device_info_json(&self) -> Result { + self.client.read().await.get_device_info().await + } + + /// Returns *device usage* as [`DeviceUsageEnergyMonitoringResult`]. + pub async fn get_device_usage(&self) -> Result { + self.client.read().await.get_device_usage().await + } + + /// Returns a [`ColorLightSetDeviceInfoParams`] builder that allows multiple properties to be set in a single request. + /// [`ColorLightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + /// For *lighting effects*, use [`RgbicLightStripHandler::set_lighting_effect`] instead. + /// + /// # Example + /// + /// ```rust,no_run + /// # use tapo::ApiClient; + /// # use tapo::requests::Color; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let device = ApiClient::new("tapo-username@example.com", "tapo-password") + /// # .l930("192.168.1.100") + /// # .await?; + /// device + /// .set() + /// .brightness(50) + /// .color(Color::HotPink) + /// .send(&device) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn set(&self) -> ColorLightSetDeviceInfoParams { + ColorLightSetDeviceInfoParams::new() + } + + /// Sets the *brightness* and turns *on* the device. + /// Pre-existing *lighting effect* will be removed. + /// + /// # Arguments + /// + /// * `brightness` - between 1 and 100 + pub async fn set_brightness(&self, brightness: u8) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .brightness(brightness) + .send(self) + .await + } + + /// Sets the *color* and turns *on* the device. + /// Pre-existing *lighting effect* will be removed. + /// + /// # Arguments + /// + /// * `color` - one of [crate::requests::Color] as defined in the Google Home app + pub async fn set_color(&self, color: Color) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .color(color) + .send(self) + .await + } + + /// Sets the *hue*, *saturation* and turns *on* the device. + /// Pre-existing *lighting effect* will be removed. + /// + /// # Arguments + /// + /// * `hue` - between 0 and 360 + /// * `saturation` - between 1 and 100 + pub async fn set_hue_saturation(&self, hue: u16, saturation: u8) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .hue_saturation(hue, saturation) + .send(self) + .await + } + + /// Sets the *color temperature* and turns *on* the device. + /// Pre-existing *lighting effect* will be removed. + /// + /// # Arguments + /// + /// * `color_temperature` - between 2500 and 6500 + pub async fn set_color_temperature(&self, color_temperature: u16) -> Result<(), Error> { + ColorLightSetDeviceInfoParams::new() + .color_temperature(color_temperature) + .send(self) + .await + } + + /// Sets a *lighting effect* and turns *on* the device. + /// + /// # Arguments + /// + /// * `lighting_effect` - [crate::requests::LightingEffectPreset] or [crate::requests::LightingEffect]. + pub async fn set_lighting_effect( + &self, + lighting_effect: impl Into, + ) -> Result<(), Error> { + self.client + .read() + .await + .set_lighting_effect(lighting_effect.into()) + .await + } +} + +#[async_trait] +impl HandlerExt for RgbicLightStripHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &ApiClient| -> &dyn ApiClientExt { client }, + ) + } +} + +impl DeviceManagementExt for RgbicLightStripHandler {} diff --git a/agents/tapo/tapo-fork/tapo/src/error.rs b/agents/tapo/tapo-fork/tapo/src/error.rs new file mode 100644 index 0000000..ded4b8f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/error.rs @@ -0,0 +1,75 @@ +/// Response Error from the Tapo API. +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum TapoResponseError { + /// Unexpected empty result. + #[error("Unexpected empty result")] + EmptyResult, + /// Forbidden access. + #[error("Forbidden: code {code}, description {description}")] + Forbidden { + /// Error code. + code: String, + /// Error description. + description: String, + }, + /// Parameters were invalid. + #[error("Invalid parameters")] + InvalidParameters, + /// Invalid public key. + #[error("Invalid public key")] + InvalidPublicKey, + /// Invalid request. + #[error("Invalid request")] + InvalidRequest, + /// Invalid response. + #[error("Invalid response")] + InvalidResponse, + /// Malformed request. + #[error("Malformed request")] + MalformedRequest, + /// Session timeout. + #[error("Session timeout")] + SessionTimeout, + /// Unauthorized access. + #[error("Unauthorized: code {code}, description {description}")] + Unauthorized { + /// Error code. + code: String, + /// Error description. + description: String, + }, + /// Unknown Error. This is a catch-all for errors that don't fit into the other categories. + /// In time, some of these might be added as their own variants. + #[error("Unknown error: {0}")] + Unknown(i32), +} + +/// Tapo API Client Error. +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum Error { + /// Response Error from the Tapo API. + #[error(transparent)] + Tapo(TapoResponseError), + /// Validation Error of a provided field. + #[error("Validation: {field} {message}")] + Validation { + /// The field that failed validation. + field: String, + /// The validation error message. + message: String, + }, + /// Serialization/Deserialization Error. + #[error(transparent)] + Serde(#[from] serde_json::Error), + /// HTTP Error. + #[error(transparent)] + Http(#[from] reqwest::Error), + /// Device not found + #[error("Device not found")] + DeviceNotFound, + /// Other Error. This is a catch-all for errors that don't fit into the other categories. + #[error(transparent)] + Other(#[from] anyhow::Error), +} diff --git a/agents/tapo/tapo-fork/tapo/src/lib.rs b/agents/tapo/tapo-fork/tapo/src/lib.rs new file mode 100644 index 0000000..db07020 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/lib.rs @@ -0,0 +1,86 @@ +#![warn(missing_docs)] + +//! Tapo API Client. +//! +//! Tested with light bulbs (L510, L520, L530, L535, L610, L630), light strips (L900, L920, L930), +//! plugs (P100, P105, P110, P110M, P115), power strips (P300, P304M, P306, P316M), hubs (H100), switches (S200B) and +//! sensors (KE100, T100, T110, T300, T310, T315). +//! +//! # Example with L530 +//! ```rust,no_run +//! use std::{env, thread, time::Duration}; +//! +//! use tapo::{requests::Color, ApiClient}; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let tapo_username = env::var("TAPO_USERNAME")?; +//! let tapo_password = env::var("TAPO_PASSWORD")?; +//! let ip_address = env::var("IP_ADDRESS")?; +//! +//! let device = ApiClient::new(tapo_username, tapo_password) +//! .l530(ip_address) +//! .await?; +//! +//! println!("Turning device on..."); +//! device.on().await?; +//! +//! println!("Setting the brightness to 30%..."); +//! device.set_brightness(30).await?; +//! +//! println!("Setting the color to `Chocolate`..."); +//! device.set_color(Color::Chocolate).await?; +//! +//! println!("Waiting 2 seconds..."); +//! thread::sleep(Duration::from_secs(2)); +//! +//! println!("Setting the color to `Deep Sky Blue` using the `hue` and `saturation`..."); +//! device.set_hue_saturation(195, 100).await?; +//! +//! println!("Waiting 2 seconds..."); +//! thread::sleep(Duration::from_secs(2)); +//! +//! println!("Setting the color to `Incandescent` using the `color temperature`..."); +//! device.set_color_temperature(2700).await?; +//! +//! println!("Waiting 2 seconds..."); +//! thread::sleep(Duration::from_secs(2)); +//! +//! println!("Using the `set` API to change multiple properties in a single request..."); +//! device +//! .set() +//! .brightness(50) +//! .color(Color::HotPink) +//! .send(&device) +//! .await?; +//! +//! println!("Waiting 2 seconds..."); +//! thread::sleep(Duration::from_secs(2)); +//! +//! println!("Turning device off..."); +//! device.off().await?; +//! +//! let device_info = device.get_device_info().await?; +//! println!("Device info: {device_info:?}"); +//! +//! let device_usage = device.get_device_usage().await?; +//! println!("Device usage: {device_usage:?}"); +//! +//! Ok(()) +//! } +//! ``` +//! +//! See [more examples](https://github.com/mihai-dinculescu/tapo/tree/main/tapo/examples). + +mod api; +mod error; +mod utils; + +#[cfg(feature = "python")] +pub mod python; + +pub mod requests; +pub mod responses; + +pub use api::*; +pub use error::*; diff --git a/agents/tapo/tapo-fork/tapo/src/python.rs b/agents/tapo/tapo-fork/tapo/src/python.rs new file mode 100644 index 0000000..bb68ceb --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/python.rs @@ -0,0 +1,51 @@ +//! Python utilities. + +use pyo3::types::{PyDict, PyDictMethods, PyList, PyListMethods}; +use pyo3::{IntoPyObjectExt, Py, PyResult, Python}; +use serde_json::Value; + +/// Converts a serde object to a Python dictionary. +pub fn serde_object_to_py_dict(py: Python, value: &Value) -> PyResult> { + let dict = PyDict::new(py); + + if let Some(object) = value.as_object() { + for (key, value) in object { + let value_mapped = map_value(py, value)?; + dict.set_item(key, value_mapped)?; + } + } + + Ok(dict.into()) +} + +fn map_value<'py>(py: Python<'py>, value: &'py Value) -> PyResult> { + let mapped_value = match value { + Value::Object(_) => serde_object_to_py_dict(py, value)?.into_py_any(py)?, + Value::Array(value) => { + let array = PyList::empty(py); + + for item in value { + let mapped_item = map_value(py, item)?; + array.append(mapped_item)?; + } + + array.into_py_any(py)? + } + Value::String(value) => IntoPyObjectExt::into_py_any(value, py)?, + Value::Bool(value) => IntoPyObjectExt::into_py_any(value, py)?, + Value::Number(value) => { + if let Some(ref value) = value.as_i64() { + IntoPyObjectExt::into_py_any(value, py)? + } else if let Some(ref value) = value.as_u64() { + IntoPyObjectExt::into_py_any(value, py)? + } else if let Some(ref value) = value.as_f64() { + IntoPyObjectExt::into_py_any(value, py)? + } else { + todo!() + } + } + Value::Null => py.None(), + }; + + Ok(mapped_value) +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests.rs b/agents/tapo/tapo-fork/tapo/src/requests.rs new file mode 100644 index 0000000..75a2f73 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests.rs @@ -0,0 +1,37 @@ +//! Tapo request objects. + +mod control_child; +mod device_reboot; +mod energy_data_interval; +mod get_child_device_list; +mod get_energy_data; +mod get_power_data; +mod get_rules; +mod get_trigger_logs; +mod handshake; +mod login_device; +mod multiple_request; +mod play_alarm; +mod power_data_interval; +mod secure_passthrough; +mod set_device_info; +mod tapo_request; + +pub use crate::responses::TemperatureUnitKE100; +pub use energy_data_interval::*; +pub use play_alarm::*; +pub use power_data_interval::*; +pub use set_device_info::*; + +pub(crate) use control_child::*; +pub(crate) use device_reboot::*; +pub(crate) use get_child_device_list::*; +pub(crate) use get_energy_data::*; +pub(crate) use get_power_data::*; +pub(crate) use get_rules::*; +pub(crate) use get_trigger_logs::*; +pub(crate) use handshake::*; +pub(crate) use login_device::*; +pub(crate) use multiple_request::*; +pub(crate) use secure_passthrough::*; +pub(crate) use tapo_request::*; diff --git a/agents/tapo/tapo-fork/tapo/src/requests/control_child.rs b/agents/tapo/tapo-fork/tapo/src/requests/control_child.rs new file mode 100644 index 0000000..1edb638 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/control_child.rs @@ -0,0 +1,19 @@ +use serde::Serialize; + +use crate::requests::tapo_request::TapoRequest; + +#[derive(Debug, Serialize)] +pub(crate) struct ControlChildParams { + device_id: String, + #[serde(rename = "requestData")] + request_data: TapoRequest, +} + +impl ControlChildParams { + pub fn new(device_id: String, request_data: TapoRequest) -> Self { + Self { + device_id, + request_data, + } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/device_reboot.rs b/agents/tapo/tapo-fork/tapo/src/requests/device_reboot.rs new file mode 100644 index 0000000..575aa4e --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/device_reboot.rs @@ -0,0 +1,12 @@ +use serde::Serialize; + +#[derive(Debug, Default, Serialize)] +pub(crate) struct DeviceRebootParams { + delay: u16, +} + +impl DeviceRebootParams { + pub fn new(delay: u16) -> Self { + Self { delay } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/energy_data_interval.rs b/agents/tapo/tapo-fork/tapo/src/requests/energy_data_interval.rs new file mode 100644 index 0000000..5004059 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/energy_data_interval.rs @@ -0,0 +1,23 @@ +use chrono::NaiveDate; + +/// Energy data interval. +pub enum EnergyDataInterval { + /// Hourly interval. `start_date` and `end_date` are an inclusive interval that must not be greater than 8 days. + Hourly { + /// Interval start date. + start_date: NaiveDate, + /// Interval end date. Inclusive. + /// Must not be greater by more than 8 days than `start_date`. + end_date: NaiveDate, + }, + /// Daily interval. `start_date` must be the first day of a quarter. + Daily { + /// Must be the first day of a quarter. + start_date: NaiveDate, + }, + /// Monthly interval. `start_date` must be the first day of a year. + Monthly { + /// Must be the first day of a year. + start_date: NaiveDate, + }, +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/get_child_device_list.rs b/agents/tapo/tapo-fork/tapo/src/requests/get_child_device_list.rs new file mode 100644 index 0000000..211e7b4 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/get_child_device_list.rs @@ -0,0 +1,12 @@ +use serde::Serialize; + +#[derive(Debug, Default, Serialize)] +pub(crate) struct GetChildDeviceListParams { + start_index: u64, +} + +impl GetChildDeviceListParams { + pub fn new(start_index: u64) -> Self { + Self { start_index } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/get_energy_data.rs b/agents/tapo/tapo-fork/tapo/src/requests/get_energy_data.rs new file mode 100644 index 0000000..c54862e --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/get_energy_data.rs @@ -0,0 +1,63 @@ +use serde::Serialize; + +use crate::requests::EnergyDataInterval; + +#[derive(Debug, Default, Serialize)] +pub(crate) struct GetEnergyDataParams { + start_timestamp: u64, + end_timestamp: u64, + interval: u64, +} + +impl GetEnergyDataParams { + pub fn new(interval: EnergyDataInterval) -> Self { + let timezone = chrono::Local::now().timezone(); + + match interval { + EnergyDataInterval::Hourly { + start_date, + end_date, + } => Self { + start_timestamp: start_date + .and_hms_opt(0, 0, 0) + .unwrap() + .and_local_timezone(timezone) + .unwrap() + .timestamp() as u64, + end_timestamp: end_date + .and_hms_opt(23, 59, 59) + .unwrap() + .and_local_timezone(timezone) + .unwrap() + .timestamp() as u64, + interval: 60, + }, + EnergyDataInterval::Daily { start_date } => { + let timestamp = start_date + .and_hms_opt(0, 0, 0) + .unwrap() + .and_local_timezone(timezone) + .unwrap() + .timestamp() as u64; + Self { + start_timestamp: timestamp, + end_timestamp: timestamp, + interval: 1440, + } + } + EnergyDataInterval::Monthly { start_date } => { + let timestamp = start_date + .and_hms_opt(0, 0, 0) + .unwrap() + .and_local_timezone(timezone) + .unwrap() + .timestamp() as u64; + Self { + start_timestamp: timestamp, + end_timestamp: timestamp, + interval: 43200, + } + } + } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/get_power_data.rs b/agents/tapo/tapo-fork/tapo/src/requests/get_power_data.rs new file mode 100644 index 0000000..5680055 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/get_power_data.rs @@ -0,0 +1,148 @@ +use chrono::{DateTime, Duration, Timelike as _, Utc}; +use serde::Serialize; + +use crate::requests::PowerDataInterval; + +#[derive(Debug, Default, Serialize)] +pub(crate) struct GetPowerDataParams { + start_timestamp: u64, + end_timestamp: u64, + interval: u64, +} + +impl GetPowerDataParams { + pub fn new(interval: PowerDataInterval) -> Self { + match interval { + PowerDataInterval::Every5Minutes { + start_date_time, + end_date_time, + } => Self { + start_timestamp: get_5_minute_interval_start(start_date_time).timestamp() as u64, + end_timestamp: get_5_minute_interval_start(end_date_time).timestamp() as u64, + interval: 5, + }, + PowerDataInterval::Hourly { + start_date_time, + end_date_time, + } => Self { + start_timestamp: get_hourly_interval_start(start_date_time).timestamp() as u64, + end_timestamp: get_hourly_interval_start(end_date_time).timestamp() as u64, + interval: 60, + }, + } + } +} + +fn get_5_minute_interval_start(date: DateTime) -> DateTime { + // Seconds since start of the hour + let secs_into_hour = (date.minute() as i64) * 60 + date.second() as i64; + let rem = secs_into_hour % 300; // 300 = 5 * 60 + + // If already exactly on a 5‑minute boundary (and second == 0) keep as-is. + if rem == 0 && date.second() == 0 { + return date + .with_second(0) + .expect("set second") + .with_nanosecond(0) + .expect("set nanos"); + } + + // Otherwise add the remaining seconds to reach the next 5‑minute boundary. + let add = 300 - rem; + let adjusted = date + Duration::seconds(add); + + adjusted + .with_second(0) + .expect("Failed to set second") + .with_nanosecond(0) + .expect("Failed to set nanos") +} + +fn get_hourly_interval_start(date: DateTime) -> DateTime { + // If already exactly on an hour boundary, keep it. + if date.minute() == 0 && date.second() == 0 && date.nanosecond() == 0 { + return date; + } + + // Truncate to the current hour then add one hour (chrono handles day rollover). + let hour_start = date + .with_minute(0) + .expect("Failed to set minute") + .with_second(0) + .expect("Failed to set second") + .with_nanosecond(0) + .expect("Failed to set nanos"); + + hour_start + Duration::hours(1) +} + +#[cfg(test)] +mod tests { + use chrono::{Datelike as _, TimeZone as _}; + + use super::*; + + #[test] + fn test_get_5_minute_interval_start() { + // Exact 5-minute interval, no change + let date = Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap(); + let adjusted = get_5_minute_interval_start(date); + assert_eq!(adjusted.hour(), 15); + assert_eq!(adjusted.minute(), 0); + assert_eq!(adjusted.second(), 0); + + // Non-exact 5-minute interval, round up to next 5-minute mark + let date = Utc.with_ymd_and_hms(2025, 1, 1, 14, 3, 45).unwrap(); + let adjusted = get_5_minute_interval_start(date); + assert_eq!(adjusted.hour(), 14); + assert_eq!(adjusted.minute(), 5); + assert_eq!(adjusted.second(), 0); + + // Non-exact 5-minute interval, round up to next hour + let date = Utc.with_ymd_and_hms(2025, 1, 1, 14, 57, 30).unwrap(); + let adjusted = get_5_minute_interval_start(date); + assert_eq!(adjusted.hour(), 15); + assert_eq!(adjusted.minute(), 0); + assert_eq!(adjusted.second(), 0); + + // Non-exact 5-minute interval, round up to next day + let date = Utc.with_ymd_and_hms(2025, 1, 1, 23, 58, 59).unwrap(); + let adjusted = get_5_minute_interval_start(date); + assert_eq!(adjusted.hour(), 0); + assert_eq!(adjusted.minute(), 0); + assert_eq!(adjusted.second(), 0); + assert_eq!(adjusted.day(), 2); + } + + #[test] + fn test_get_hourly_interval_start() { + // Exact hour, no change + let date = Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap(); + let adjusted = get_hourly_interval_start(date); + assert_eq!(adjusted.hour(), 15); + assert_eq!(adjusted.minute(), 0); + assert_eq!(adjusted.second(), 0); + + // Non-exact hour, round up to next hour + let date = Utc.with_ymd_and_hms(2025, 1, 1, 14, 15, 0).unwrap(); + let adjusted = get_hourly_interval_start(date); + assert_eq!(adjusted.hour(), 15); + assert_eq!(adjusted.minute(), 0); + assert_eq!(adjusted.second(), 0); + + // Non-exact hour, round up to next hour + let date = Utc.with_ymd_and_hms(2025, 1, 1, 14, 0, 30).unwrap(); + let adjusted = get_hourly_interval_start(date); + assert_eq!(adjusted.hour(), 15); + assert_eq!(adjusted.minute(), 0); + assert_eq!(adjusted.second(), 0); + + // Non-exact hour, round up to next day + let date = Utc.with_ymd_and_hms(2025, 1, 1, 23, 30, 59).unwrap(); + let adjusted = get_hourly_interval_start(date); + assert_eq!(adjusted.hour(), 0); + assert_eq!(adjusted.minute(), 0); + assert_eq!(adjusted.second(), 0); + assert_eq!(adjusted.day(), 2); + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/get_rules.rs b/agents/tapo/tapo-fork/tapo/src/requests/get_rules.rs new file mode 100644 index 0000000..8b315ad --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/get_rules.rs @@ -0,0 +1,19 @@ +use serde::Serialize; + +/// Parameters for getting schedule/countdown rules +#[derive(Debug, Serialize)] +pub(crate) struct GetRulesParams { + pub start_index: u32, +} + +impl GetRulesParams { + pub fn new(start_index: u32) -> Self { + Self { start_index } + } +} + +impl Default for GetRulesParams { + fn default() -> Self { + Self { start_index: 0 } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/get_trigger_logs.rs b/agents/tapo/tapo-fork/tapo/src/requests/get_trigger_logs.rs new file mode 100644 index 0000000..aadb679 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/get_trigger_logs.rs @@ -0,0 +1,16 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub(crate) struct GetTriggerLogsParams { + page_size: u64, + start_id: u64, +} + +impl GetTriggerLogsParams { + pub fn new(page_size: u64, start_id: u64) -> Self { + Self { + page_size, + start_id, + } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/handshake.rs b/agents/tapo/tapo-fork/tapo/src/requests/handshake.rs new file mode 100644 index 0000000..cbdca5d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/handshake.rs @@ -0,0 +1,12 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub(crate) struct HandshakeParams { + key: String, +} + +impl HandshakeParams { + pub fn new(key: String) -> Self { + Self { key } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/login_device.rs b/agents/tapo/tapo-fork/tapo/src/requests/login_device.rs new file mode 100644 index 0000000..a53ede0 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/login_device.rs @@ -0,0 +1,28 @@ +use std::fmt; + +use serde::Serialize; + +#[derive(Serialize)] +pub(crate) struct LoginDeviceParams { + username: String, + password: String, +} + +impl LoginDeviceParams { + pub fn new(username: &str, password: &str) -> Self { + Self { + username: username.to_string(), + password: password.to_string(), + } + } +} + +impl fmt::Debug for LoginDeviceParams { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + r#"LoginDeviceParams {{ username: "{}", password: "OBSCURED" }}"#, + self.username, + ) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/multiple_request.rs b/agents/tapo/tapo-fork/tapo/src/requests/multiple_request.rs new file mode 100644 index 0000000..a338483 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/multiple_request.rs @@ -0,0 +1,14 @@ +use serde::Serialize; + +use crate::requests::TapoRequest; + +#[derive(Debug, Serialize)] +pub(crate) struct MultipleRequestParams { + requests: Vec, +} + +impl MultipleRequestParams { + pub fn new(requests: Vec) -> Self { + Self { requests } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/play_alarm.rs b/agents/tapo/tapo-fork/tapo/src/requests/play_alarm.rs new file mode 100644 index 0000000..ac634d7 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/play_alarm.rs @@ -0,0 +1,310 @@ +use crate::Error; +use serde::{Serialize, Serializer}; + +/// The volume of the alarm. +/// For the H100, this is a fixed list of volume levels. +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +pub enum AlarmVolume { + /// Use the default volume for the hub. + #[default] + Default, + /// Mute the audio output from the alarm. + /// This causes the alarm to be shown as triggered in the Tapo App + /// without an audible sound, and makes the `in_alarm` property + /// in [`crate::responses::DeviceInfoHubResult`] return as `true`. + Mute, + /// Lowest volume. + Low, + /// Normal volume. This is the default. + Normal, + /// Highest volume. + High, +} + +impl AlarmVolume { + fn is_default(&self) -> bool { + matches!(self, Self::Default) + } +} + +/// The ringtone of a H100 alarm. +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +pub enum AlarmRingtone { + /// Use the default ringtone for the hub. + #[default] + Default, + /// Alarm 1 + #[serde(rename = "Alarm 1")] + Alarm1, + /// Alarm 2 + #[serde(rename = "Alarm 2")] + Alarm2, + /// Alarm 3 + #[serde(rename = "Alarm 3")] + Alarm3, + /// Alarm 4 + #[serde(rename = "Alarm 4")] + Alarm4, + /// Alarm 5 + #[serde(rename = "Alarm 5")] + Alarm5, + /// Connection 1 + #[serde(rename = "Connection 1")] + Connection1, + /// Connection 2 + #[serde(rename = "Connection 2")] + Connection2, + /// Doorbell Ring 1 + #[serde(rename = "Doorbell Ring 1")] + DoorbellRing1, + /// Doorbell Ring 2 + #[serde(rename = "Doorbell Ring 2")] + DoorbellRing2, + /// Doorbell Ring 3 + #[serde(rename = "Doorbell Ring 3")] + DoorbellRing3, + /// Doorbell Ring 4 + #[serde(rename = "Doorbell Ring 4")] + DoorbellRing4, + /// Doorbell Ring 5 + #[serde(rename = "Doorbell Ring 5")] + DoorbellRing5, + /// Doorbell Ring 6 + #[serde(rename = "Doorbell Ring 6")] + DoorbellRing6, + /// Doorbell Ring 7 + #[serde(rename = "Doorbell Ring 7")] + DoorbellRing7, + /// Doorbell Ring 8 + #[serde(rename = "Doorbell Ring 8")] + DoorbellRing8, + /// Doorbell Ring 9 + #[serde(rename = "Doorbell Ring 9")] + DoorbellRing9, + /// Doorbell Ring 10 + #[serde(rename = "Doorbell Ring 10")] + DoorbellRing10, + /// Dripping Tap + #[serde(rename = "Dripping Tap")] + DrippingTap, + /// Phone Ring + #[serde(rename = "Phone Ring")] + PhoneRing, +} + +impl AlarmRingtone { + fn is_default(&self) -> bool { + matches!(self, Self::Default) + } +} + +/// Controls how long the alarm plays for. +#[derive(Debug, Clone, Copy)] +pub enum AlarmDuration { + /// Play the alarm continuously until stopped. + Continuous, + /// Play the alarm once. + /// This is useful for previewing the audio. + /// + /// # Limitations + /// The `in_alarm` field of [`crate::responses::DeviceInfoHubResult`] will not remain `true` for the + /// duration of the audio track. Each audio track has a different runtime. + /// + /// Has no observable affect when used in conjunction with [`AlarmVolume::Mute`]. + Once, + /// Play the alarm a number of seconds. + Seconds(u32), +} +impl AlarmDuration { + fn is_continuous(&self) -> bool { + matches!(self, Self::Continuous) + } +} +impl Serialize for AlarmDuration { + fn serialize(&self, serializer: S) -> Result { + let as_option = match self { + Self::Continuous => None, + Self::Once => Some(0), + Self::Seconds(seconds) => Some(*seconds), + }; + Serialize::serialize(&as_option, serializer) + } +} + +/// Parameters for playing the alarm on a H100 hub. +#[derive(Debug, Serialize)] +pub(crate) struct PlayAlarmParams { + #[serde(skip_serializing_if = "AlarmRingtone::is_default")] + alarm_type: AlarmRingtone, + #[serde(skip_serializing_if = "AlarmVolume::is_default")] + alarm_volume: AlarmVolume, + #[serde(skip_serializing_if = "AlarmDuration::is_continuous")] + alarm_duration: AlarmDuration, +} +impl PlayAlarmParams { + pub(crate) fn new( + ringtone: AlarmRingtone, + volume: AlarmVolume, + duration: AlarmDuration, + ) -> Result { + let params = Self { + alarm_type: ringtone, + alarm_volume: volume, + alarm_duration: duration, + }; + params.validate()?; + Ok(params) + } + + fn validate(&self) -> Result<(), Error> { + match self.alarm_duration { + AlarmDuration::Seconds(0) => Err(Error::Validation { + field: "duration".to_string(), + message: "The seconds value must be greater than zero".to_string(), + }), + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_inputs() { + for valid_ringtone in [AlarmRingtone::Default, AlarmRingtone::Alarm1] { + for valid_volume in [AlarmVolume::Default, AlarmVolume::Normal] { + for valid_duration in [ + AlarmDuration::Continuous, + AlarmDuration::Once, + AlarmDuration::Seconds(1), + ] { + let result = PlayAlarmParams::new(valid_ringtone, valid_volume, valid_duration); + assert!(result.is_ok()); + } + } + } + } + + #[test] + fn test_invalid_inputs() { + let result = PlayAlarmParams::new( + AlarmRingtone::Default, + AlarmVolume::Default, + AlarmDuration::Seconds(0), + ); + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "duration" && message == "The seconds value must be greater than zero" + )); + } + + fn params_to_json( + ringtone: AlarmRingtone, + volume: AlarmVolume, + duration: AlarmDuration, + ) -> String { + let params = PlayAlarmParams::new(ringtone, volume, duration).unwrap(); + serde_json::to_string(¶ms).expect("Serialization failed") + } + + #[test] + fn test_serialize_params_where_ringtone_is_some() { + assert_eq!( + r#"{"alarm_type":"Alarm 1"}"#, + params_to_json( + AlarmRingtone::Alarm1, + AlarmVolume::Default, + AlarmDuration::Continuous + ) + ); + } + + #[test] + fn test_serialize_params_where_volume_is_some() { + assert_eq!( + r#"{"alarm_volume":"mute"}"#, + params_to_json( + AlarmRingtone::Default, + AlarmVolume::Mute, + AlarmDuration::Continuous + ) + ); + assert_eq!( + r#"{"alarm_volume":"low"}"#, + params_to_json( + AlarmRingtone::Default, + AlarmVolume::Low, + AlarmDuration::Continuous + ) + ); + assert_eq!( + r#"{"alarm_volume":"normal"}"#, + params_to_json( + AlarmRingtone::Default, + AlarmVolume::Normal, + AlarmDuration::Continuous + ) + ); + assert_eq!( + r#"{"alarm_volume":"high"}"#, + params_to_json( + AlarmRingtone::Default, + AlarmVolume::High, + AlarmDuration::Continuous + ) + ); + } + + #[test] + fn test_serialize_params_where_duration_is_continuous() { + assert_eq!( + r#"{}"#, + params_to_json( + AlarmRingtone::Default, + AlarmVolume::Default, + AlarmDuration::Continuous + ) + ); + } + + #[test] + fn test_serialize_params_where_duration_is_once() { + assert_eq!( + r#"{"alarm_duration":0}"#, + params_to_json( + AlarmRingtone::Default, + AlarmVolume::Default, + AlarmDuration::Once + ) + ); + } + + #[test] + fn test_serialize_params_where_duration_is_1second() { + assert_eq!( + r#"{"alarm_duration":1}"#, + params_to_json( + AlarmRingtone::Default, + AlarmVolume::Default, + AlarmDuration::Seconds(1) + ) + ); + } + + #[test] + fn test_serialize_all_params_are_some_and_duration_is_1second() { + assert_eq!( + r#"{"alarm_type":"Doorbell Ring 1","alarm_volume":"normal","alarm_duration":1}"#, + params_to_json( + AlarmRingtone::DoorbellRing1, + AlarmVolume::Normal, + AlarmDuration::Seconds(1) + ) + ); + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/power_data_interval.rs b/agents/tapo/tapo-fork/tapo/src/requests/power_data_interval.rs new file mode 100644 index 0000000..f722514 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/power_data_interval.rs @@ -0,0 +1,25 @@ +use chrono::{DateTime, Utc}; + +/// Power data interval. +pub enum PowerDataInterval { + /// Every 5 minutes interval. `start_date_time` and `end_date_time` describe an exclusive interval. + /// If the result would yield more than 144 entries (i.e. 12 hours), + /// the `end_date_time` will be adjusted to an earlier date and time. + Every5Minutes { + /// Start date and time in UTC. + /// If it is not aligned to the 5 minute mark, it will be rounded to the next 5 minute mark. + start_date_time: DateTime, + /// End date and time in UTC. + end_date_time: DateTime, + }, + /// Hourly interval. `start_date_time` and `end_date_time` describe an exclusive interval. + /// If the result would yield more than 144 entries (i.e. 6 days), + /// the `end_date_time` will be adjusted to an earlier date and time. + Hourly { + /// Start date and time in UTC. + /// If it is not aligned to the hour mark, it will be rounded to the next hour mark. + start_date_time: DateTime, + /// End date and time in UTC. + end_date_time: DateTime, + }, +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/secure_passthrough.rs b/agents/tapo/tapo-fork/tapo/src/requests/secure_passthrough.rs new file mode 100644 index 0000000..aa6c7dd --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/secure_passthrough.rs @@ -0,0 +1,14 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub(crate) struct SecurePassthroughParams { + request: String, +} + +impl SecurePassthroughParams { + pub fn new(request: &str) -> Self { + Self { + request: request.to_string(), + } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/set_device_info.rs b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info.rs new file mode 100644 index 0000000..a6a6e1b --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info.rs @@ -0,0 +1,14 @@ +mod color; +mod color_light; +mod generic_device; +mod light; +mod lighting_effect; +mod trv; + +pub use color::*; +pub use color_light::*; +pub use lighting_effect::*; + +pub(crate) use generic_device::*; +pub(crate) use light::*; +pub(crate) use trv::*; diff --git a/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/color.rs b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/color.rs new file mode 100644 index 0000000..bf8c26b --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/color.rs @@ -0,0 +1,114 @@ +use std::collections::HashMap; + +use lazy_static::lazy_static; +use serde::{Deserialize, Serialize}; + +/// List of preset colors as defined in the Google Home app. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum Color { + CoolWhite, + Daylight, + Ivory, + WarmWhite, + Incandescent, + Candlelight, + Snow, + GhostWhite, + AliceBlue, + LightGoldenrod, + LemonChiffon, + AntiqueWhite, + Gold, + Peru, + Chocolate, + SandyBrown, + Coral, + Pumpkin, + Tomato, + Vermilion, + OrangeRed, + Pink, + Crimson, + DarkRed, + HotPink, + Smitten, + MediumPurple, + BlueViolet, + Indigo, + LightSkyBlue, + CornflowerBlue, + Ultramarine, + DeepSkyBlue, + Azure, + NavyBlue, + LightTurquoise, + Aquamarine, + Turquoise, + LightGreen, + Lime, + ForestGreen, +} + +#[cfg_attr(feature = "python", pyo3::pymethods)] +impl Color { + /// Get the [`crate::requests::ColorConfig`] of the color. + pub fn get_color_config(&self) -> ColorConfig { + COLOR_MAP + .get(self) + .cloned() + .unwrap_or_else(|| panic!("Failed to find the color definition of {self:?}")) + } +} + +/// Triple-tuple containing the `hue`, `saturation`, and `color_temperature` of a color. +pub type ColorConfig = (u16, u8, u16); + +lazy_static! { + static ref COLOR_MAP: HashMap = { + let mut map = HashMap::::new(); + map.insert(Color::CoolWhite, (0, 100, 4000)); + map.insert(Color::Daylight, (0, 100, 5000)); + map.insert(Color::Ivory, (0, 100, 6000)); + map.insert(Color::WarmWhite, (0, 100, 3000)); + map.insert(Color::Incandescent, (0, 100, 2700)); + map.insert(Color::Candlelight, (0, 100, 2500)); + map.insert(Color::Snow, (0, 100, 6500)); + map.insert(Color::GhostWhite, (0, 100, 6500)); + map.insert(Color::AliceBlue, (208, 5, 0)); + map.insert(Color::LightGoldenrod, (54, 28, 0)); + map.insert(Color::LemonChiffon, (54, 19, 0)); + map.insert(Color::AntiqueWhite, (0, 100, 5500)); + map.insert(Color::Gold, (50, 100, 0)); + map.insert(Color::Peru, (29, 69, 0)); + map.insert(Color::Chocolate, (30, 100, 0)); + map.insert(Color::SandyBrown, (27, 60, 0)); + map.insert(Color::Coral, (16, 68, 0)); + map.insert(Color::Pumpkin, (24, 90, 0)); + map.insert(Color::Tomato, (9, 72, 0)); + map.insert(Color::Vermilion, (4, 77, 0)); + map.insert(Color::OrangeRed, (16, 100, 0)); + map.insert(Color::Pink, (349, 24, 0)); + map.insert(Color::Crimson, (348, 90, 0)); + map.insert(Color::DarkRed, (0, 100, 0)); + map.insert(Color::HotPink, (330, 58, 0)); + map.insert(Color::Smitten, (329, 67, 0)); + map.insert(Color::MediumPurple, (259, 48, 0)); + map.insert(Color::BlueViolet, (271, 80, 0)); + map.insert(Color::Indigo, (274, 100, 0)); + map.insert(Color::LightSkyBlue, (202, 46, 0)); + map.insert(Color::CornflowerBlue, (218, 57, 0)); + map.insert(Color::Ultramarine, (254, 100, 0)); + map.insert(Color::DeepSkyBlue, (195, 100, 0)); + map.insert(Color::Azure, (210, 100, 0)); + map.insert(Color::NavyBlue, (240, 100, 0)); + map.insert(Color::LightTurquoise, (180, 26, 0)); + map.insert(Color::Aquamarine, (159, 50, 0)); + map.insert(Color::Turquoise, (174, 71, 0)); + map.insert(Color::LightGreen, (120, 39, 0)); + map.insert(Color::Lime, (75, 100, 0)); + map.insert(Color::ForestGreen, (120, 75, 0)); + map + }; +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/color_light.rs b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/color_light.rs new file mode 100644 index 0000000..925c12f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/color_light.rs @@ -0,0 +1,330 @@ +use std::ops::RangeInclusive; + +use serde::Serialize; + +use crate::HandlerExt; +use crate::error::Error; +use crate::requests::Color; + +/// Builder that is used by the [`crate::ColorLightHandler::set`] API to set multiple properties in a single request. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ColorLightSetDeviceInfoParams { + #[serde(skip_serializing_if = "Option::is_none")] + device_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + brightness: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hue: Option, + #[serde(skip_serializing_if = "Option::is_none")] + saturation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "color_temp")] + color_temperature: Option, +} + +impl ColorLightSetDeviceInfoParams { + /// Turns *on* the device. [`ColorLightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + pub fn on(mut self) -> Self { + self.device_on = Some(true); + self + } + + /// Turns *off* the device. [`ColorLightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + pub fn off(mut self) -> Self { + self.device_on = Some(false); + self + } + + /// Sets the *brightness*. [`ColorLightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + /// The device will also be turned *on*, unless [`ColorLightSetDeviceInfoParams::off`] is called. + /// + /// # Arguments + /// + /// * `brightness` - between 1 and 100 + pub fn brightness(mut self, value: u8) -> Self { + self.brightness = Some(value); + self + } + + /// Sets the *color*. [`ColorLightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + /// The device will also be turned *on*, unless [`ColorLightSetDeviceInfoParams::off`] is called. + /// + /// # Arguments + /// + /// * `color` - one of [crate::requests::Color] + pub fn color(mut self, color: Color) -> Self { + let (hue, saturation, color_temperature) = color.get_color_config(); + + self.hue = Some(hue); + self.saturation = Some(saturation); + self.color_temperature = Some(color_temperature); + + self + } + + /// Sets the *hue* and *saturation*. [`ColorLightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + /// The device will also be turned *on*, unless [`ColorLightSetDeviceInfoParams::off`] is called. + /// + /// # Arguments + /// + /// * `hue` - between 0 and 360 + /// * `saturation` - between 1 and 100 + pub fn hue_saturation(mut self, hue: u16, saturation: u8) -> Self { + self.hue = Some(hue); + self.saturation = Some(saturation); + self.color_temperature = Some(0); + + self + } + + /// Sets the *color temperature*. [`ColorLightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + /// The device will also be turned *on*, unless [`ColorLightSetDeviceInfoParams::off`] is called. + /// + /// # Arguments + /// + /// * `color_temperature` - between 2500 and 6500 + pub fn color_temperature(mut self, value: u16) -> Self { + self.hue = None; + self.saturation = None; + self.color_temperature = Some(value); + + self + } + + /// Performs a request to apply the changes to the device. + /// + /// # Arguments + /// + /// * `handler` - `ColorLightHandler`, `RgbLightStripHandler`, or `RgbicLightStripHandler` instance + pub async fn send(self, handler: &impl HandlerExt) -> Result<(), Error> { + self.validate()?; + let json = serde_json::to_value(&self)?; + handler.get_client().await.set_device_info(json).await + } +} + +impl ColorLightSetDeviceInfoParams { + /// Creates a new [`ColorLightSetDeviceInfoParams`] builder. + pub fn new() -> Self { + Self::default() + } + + fn validate(&self) -> Result<(), Error> { + if self.device_on.is_none() + && self.brightness.is_none() + && self.hue.is_none() + && self.saturation.is_none() + && self.color_temperature.is_none() + { + return Err(Error::Validation { + field: "DeviceInfoParams".to_string(), + message: "Requires at least one property".to_string(), + }); + } + + if let Some(brightness) = self.brightness + && !(1..=100).contains(&brightness) + { + return Err(Error::Validation { + field: "brightness".to_string(), + message: "Must be between 1 and 100".to_string(), + }); + } + + if let Some(hue) = self.hue + && !(0..=360).contains(&hue) + { + return Err(Error::Validation { + field: "hue".to_string(), + message: "Must be between 0 and 360".to_string(), + }); + } + + if let Some(saturation) = self.saturation + && !(1..=100).contains(&saturation) + { + return Err(Error::Validation { + field: "saturation".to_string(), + message: "Must be between 1 and 100".to_string(), + }); + } + + if (self.saturation.is_some() && self.hue.is_none()) + || (self.hue.is_some() && self.saturation.is_none()) + { + return Err(Error::Validation { + field: "hue_saturation".to_string(), + message: "hue and saturation must either be both set or unset".to_string(), + }); + } + + const COLOR_TEMPERATURE_RANGE: RangeInclusive = 2500..=6500; + if let Some(color_temperature) = self.color_temperature + && self.hue.is_none() + && self.saturation.is_none() + && !COLOR_TEMPERATURE_RANGE.contains(&color_temperature) + { + return Err(Error::Validation { + field: "color_temperature".to_string(), + message: "Must be between 2500 and 6500".to_string(), + }); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use async_trait::async_trait; + use tokio::sync::{RwLock, RwLockReadGuard}; + + use crate::ApiClientExt; + + use super::*; + + #[derive(Debug)] + struct MockApiClient; + + #[async_trait] + impl ApiClientExt for MockApiClient { + async fn set_device_info(&self, _: serde_json::Value) -> Result<(), Error> { + Ok(()) + } + async fn device_reboot(&self, _: u16) -> Result<(), Error> { + unimplemented!() + } + async fn device_reset(&self) -> Result<(), Error> { + unimplemented!() + } + } + + #[derive(Debug)] + struct MockHandler; + + #[async_trait] + impl HandlerExt for MockHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + static CLIENT: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| RwLock::new(MockApiClient)); + + RwLockReadGuard::map( + CLIENT.read().await, + |client: &MockApiClient| -> &dyn ApiClientExt { client }, + ) + } + } + + #[tokio::test] + async fn hue_saturation_overrides_color_temperature() { + let params = ColorLightSetDeviceInfoParams::new(); + + let params = params.color_temperature(3000); + let params = params.hue_saturation(50, 50); + + assert_eq!(params.hue, Some(50)); + assert_eq!(params.saturation, Some(50)); + assert_eq!(params.color_temperature, Some(0)); + + assert!(params.send(&MockHandler).await.is_ok()) + } + + #[tokio::test] + async fn color_temperature_overrides_hue_saturation() { + let params = ColorLightSetDeviceInfoParams::new(); + + let params = params.hue_saturation(50, 50); + let params = params.color_temperature(3000); + + assert_eq!(params.hue, None); + assert_eq!(params.saturation, None); + assert_eq!(params.color_temperature, Some(3000)); + + assert!(params.send(&MockHandler).await.is_ok()) + } + + #[tokio::test] + async fn no_property_validation() { + let params = ColorLightSetDeviceInfoParams::new(); + let result = params.send(&MockHandler).await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "DeviceInfoParams" && message == "Requires at least one property" + )); + } + + #[tokio::test] + async fn brightness_validation() { + let params = ColorLightSetDeviceInfoParams::new(); + let result = params.brightness(0).send(&MockHandler).await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "brightness" && message == "Must be between 1 and 100" + )); + + let params = ColorLightSetDeviceInfoParams::new(); + let result = params.brightness(101).send(&MockHandler).await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "brightness" && message == "Must be between 1 and 100" + )); + } + + #[tokio::test] + async fn hue_validation() { + let params = ColorLightSetDeviceInfoParams::new(); + let result = params.hue_saturation(361, 50).send(&MockHandler).await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "hue" && message == "Must be between 0 and 360" + )); + } + + #[tokio::test] + async fn saturation_validation() { + let params = ColorLightSetDeviceInfoParams::new(); + let result = params.hue_saturation(1, 0).send(&MockHandler).await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "saturation" && message == "Must be between 1 and 100" + )); + + let params = ColorLightSetDeviceInfoParams::new(); + let result = params.hue_saturation(1, 101).send(&MockHandler).await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "saturation" && message == "Must be between 1 and 100" + )); + } + + #[tokio::test] + async fn color_temperature_validation_low() { + let params: ColorLightSetDeviceInfoParams = ColorLightSetDeviceInfoParams::new(); + let result = params.color_temperature(2499).send(&MockHandler).await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "color_temperature" && message == "Must be between 2500 and 6500" + )); + } + + #[tokio::test] + async fn color_temperature_validation_high() { + let params = ColorLightSetDeviceInfoParams::new(); + let result = params.color_temperature(6501).send(&MockHandler).await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "color_temperature" && message == "Must be between 2500 and 6500" + )); + } + + #[tokio::test] + async fn color_temperature_validation_default_hue_saturation() { + let params: ColorLightSetDeviceInfoParams = ColorLightSetDeviceInfoParams::new(); + let result = params + .color_temperature(2500) + .hue_saturation(0, 100) + .send(&MockHandler) + .await; + assert!(result.is_ok()); + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/generic_device.rs b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/generic_device.rs new file mode 100644 index 0000000..4486058 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/generic_device.rs @@ -0,0 +1,29 @@ +use serde::Serialize; + +use crate::error::Error; + +#[derive(Debug, Default, Serialize)] +pub(crate) struct GenericSetDeviceInfoParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub device_on: Option, +} + +impl GenericSetDeviceInfoParams { + pub fn device_on(value: bool) -> Result { + Self { + device_on: Some(value), + } + .validate() + } + + pub fn validate(self) -> Result { + if self.device_on.is_none() { + return Err(Error::Validation { + field: "DeviceInfoParams".to_string(), + message: "Requires at least one property".to_string(), + }); + } + + Ok(self) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/light.rs b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/light.rs new file mode 100644 index 0000000..f687262 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/light.rs @@ -0,0 +1,160 @@ +use serde::Serialize; +use tokio::sync::RwLockReadGuard; + +use crate::api::ApiClientExt; +use crate::error::Error; + +/// Builder that is used by the [`crate::LightHandler::set`] API to set multiple properties in a single request. +#[derive(Debug, Serialize)] +pub(crate) struct LightSetDeviceInfoParams<'a> { + #[serde(skip)] + client: RwLockReadGuard<'a, dyn ApiClientExt>, + #[serde(skip_serializing_if = "Option::is_none")] + device_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + brightness: Option, +} + +impl LightSetDeviceInfoParams<'_> { + /// Turns *on* the device. [`LightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + pub fn on(mut self) -> Self { + self.device_on = Some(true); + self + } + + /// Turns *off* the device. [`LightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + pub fn off(mut self) -> Self { + self.device_on = Some(false); + self + } + + /// Sets the *brightness*. [`LightSetDeviceInfoParams::send`] must be called at the end to apply the changes. + /// The device will also be turned *on*, unless [`LightSetDeviceInfoParams::off`] is called. + /// + /// # Arguments + /// + /// * `brightness` - between 1 and 100 + pub fn brightness(mut self, value: u8) -> Self { + self.brightness = Some(value); + self + } + + /// Performs a request to apply the changes to the device. + pub async fn send(self) -> Result<(), Error> { + self.validate()?; + let json = serde_json::to_value(&self)?; + self.client.set_device_info(json).await + } +} + +impl<'a> LightSetDeviceInfoParams<'a> { + pub(crate) fn new(client: RwLockReadGuard<'a, dyn ApiClientExt>) -> Self { + Self { + client, + device_on: None, + brightness: None, + } + } + + fn validate(&self) -> Result<(), Error> { + if self.device_on.is_none() && self.brightness.is_none() { + return Err(Error::Validation { + field: "DeviceInfoParams".to_string(), + message: "Requires at least one property".to_string(), + }); + } + + if let Some(brightness) = self.brightness + && !(1..=100).contains(&brightness) + { + return Err(Error::Validation { + field: "brightness".to_string(), + message: "Must be between 1 and 100".to_string(), + }); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use async_trait::async_trait; + use tokio::sync::RwLock; + + use crate::HandlerExt; + + use super::*; + + #[derive(Debug)] + struct MockApiClient; + + #[async_trait] + impl ApiClientExt for MockApiClient { + async fn set_device_info(&self, _: serde_json::Value) -> Result<(), Error> { + Ok(()) + } + async fn device_reboot(&self, _: u16) -> Result<(), Error> { + unimplemented!() + } + async fn device_reset(&self) -> Result<(), Error> { + unimplemented!() + } + } + + struct MockHandler { + client: RwLock, + } + + impl MockHandler { + fn new() -> Self { + Self { + client: RwLock::new(MockApiClient), + } + } + } + + #[async_trait] + impl HandlerExt for MockHandler { + async fn get_client(&self) -> RwLockReadGuard<'_, dyn ApiClientExt> { + RwLockReadGuard::map( + self.client.read().await, + |client: &MockApiClient| -> &dyn ApiClientExt { client }, + ) + } + } + + #[tokio::test] + async fn no_property_validation() { + let handler = MockHandler::new(); + let client = handler.get_client().await; + + let params = LightSetDeviceInfoParams::new(client); + let result = params.send().await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "DeviceInfoParams" && message == "Requires at least one property" + )); + } + + #[tokio::test] + async fn brightness_validation() { + let handler = MockHandler::new(); + let client = handler.get_client().await; + + let params = LightSetDeviceInfoParams::new(client); + let result = params.brightness(0).send().await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "brightness" && message == "Must be between 1 and 100" + )); + + let client = handler.get_client().await; + let params = LightSetDeviceInfoParams::new(client); + let result = params.brightness(101).send().await; + assert!(matches!( + result.err(), + Some(Error::Validation { field, message }) if field == "brightness" && message == "Must be between 1 and 100" + )); + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/lighting_effect.rs b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/lighting_effect.rs new file mode 100644 index 0000000..f897813 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/lighting_effect.rs @@ -0,0 +1,784 @@ +use serde::{Deserialize, Serialize}; +use serde_with::{BoolFromInt, serde_as}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum LightingEffectType { + Sequence, + Random, + Pulse, + Static, +} + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct LightingEffect { + // Mandatory + pub brightness: u8, + #[serde_as(as = "BoolFromInt")] + #[serde(rename = "custom")] + pub is_custom: bool, + /// The colors that will be displayed in the Tapo app. + pub display_colors: Vec<[u16; 3]>, + #[serde_as(as = "BoolFromInt")] + #[serde(rename = "enable")] + pub enabled: bool, + pub id: String, + pub name: String, + pub r#type: LightingEffectType, + // Optional + #[serde(skip_serializing_if = "Option::is_none")] + pub backgrounds: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub brightness_range: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub direction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expansion_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "fadeoff")] + pub fade_off: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hue_range: Option<[u16; 2]>, + #[serde(skip_serializing_if = "Option::is_none")] + pub init_states: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub random_seed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub repeat_times: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub run_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub saturation_range: Option<[u8; 2]>, + #[serde(skip_serializing_if = "Option::is_none")] + pub segment_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub segments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sequence: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub spread: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transition_range: Option<[u16; 2]>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "trans_sequence")] + pub transition_sequence: Option>, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl LightingEffect { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +#[allow(missing_docs)] +impl LightingEffect { + pub fn new( + name: impl Into, + r#type: LightingEffectType, + is_custom: bool, + enabled: bool, + brightness: u8, + display_colors: Vec<[u16; 3]>, + ) -> Self { + Self { + // Mandatory + brightness, + is_custom, + display_colors, + enabled, + id: uuid::Uuid::new_v4().simple().to_string(), + name: name.into(), + r#type, + // Optional + backgrounds: None, + brightness_range: None, + direction: None, + duration: None, + expansion_strategy: None, + fade_off: None, + hue_range: None, + init_states: None, + random_seed: None, + repeat_times: None, + run_time: None, + saturation_range: None, + segment_length: None, + segments: None, + sequence: None, + spread: None, + transition_range: None, + transition_sequence: None, + transition: None, + } + } + + pub fn with_brightness(mut self, brightness: u8) -> Self { + self.brightness = brightness; + self + } + + pub fn with_is_custom(mut self, is_custom: bool) -> Self { + self.is_custom = is_custom; + self + } + + pub fn with_display_colors(mut self, display_colors: Vec<[u16; 3]>) -> Self { + self.display_colors = display_colors; + self + } + + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + pub fn with_type(mut self, r#type: LightingEffectType) -> Self { + self.r#type = r#type; + self + } + + pub fn with_backgrounds(mut self, backgrounds: Vec<[u16; 3]>) -> Self { + self.backgrounds = Some(backgrounds); + self + } + + pub fn with_brightness_range(mut self, brightness_range: [u8; 2]) -> Self { + self.brightness_range = Some(brightness_range.to_vec()); + self + } + + pub fn with_direction(mut self, direction: u8) -> Self { + self.direction = Some(direction); + self + } + + pub fn with_duration(mut self, duration: u64) -> Self { + self.duration = Some(duration); + self + } + + pub fn with_expansion_strategy(mut self, expansion_strategy: u8) -> Self { + self.expansion_strategy = Some(expansion_strategy); + self + } + + pub fn with_fade_off(mut self, fade_off: u16) -> Self { + self.fade_off = Some(fade_off); + self + } + + pub fn with_hue_range(mut self, hue_range: [u16; 2]) -> Self { + self.hue_range = Some(hue_range); + self + } + + pub fn with_init_states(mut self, init_states: Vec<[u16; 3]>) -> Self { + self.init_states = Some(init_states); + self + } + + pub fn with_random_seed(mut self, random_seed: u64) -> Self { + self.random_seed = Some(random_seed); + self + } + + pub fn with_repeat_times(mut self, repeat_times: u8) -> Self { + self.repeat_times = Some(repeat_times); + self + } + + pub fn with_run_time(mut self, run_time: u64) -> Self { + self.run_time = Some(run_time); + self + } + + pub fn with_saturation_range(mut self, saturation_range: [u8; 2]) -> Self { + self.saturation_range = Some(saturation_range); + self + } + + pub fn with_segment_length(mut self, segment_length: u8) -> Self { + self.segment_length = Some(segment_length); + self + } + + pub fn with_segments(mut self, segments: Vec) -> Self { + self.segments = Some(segments); + self + } + + pub fn with_sequence(mut self, sequence: Vec<[u16; 3]>) -> Self { + self.sequence = Some(sequence); + self + } + + pub fn with_spread(mut self, spread: u8) -> Self { + self.spread = Some(spread); + self + } + + pub fn with_transition(mut self, transition: u16) -> Self { + self.transition = Some(transition); + self + } + + pub fn with_transition_range(mut self, transition_range: [u16; 2]) -> Self { + self.transition_range = Some(transition_range); + self + } + + pub fn with_transition_sequence(mut self, transition_sequence: Vec) -> Self { + self.transition_sequence = Some(transition_sequence); + self + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[non_exhaustive] +#[allow(missing_docs)] +pub enum LightingEffectPreset { + Aurora, + BubblingCauldron, + CandyCane, + Christmas, + Flicker, + GrandmasChristmasLights, + Hanukkah, + HauntedMansion, + Icicle, + Lightning, + Ocean, + Rainbow, + Raindrop, + Spring, + Sunrise, + Sunset, + Valentines, +} + +impl From for LightingEffect { + fn from(val: LightingEffectPreset) -> Self { + match val { + LightingEffectPreset::Aurora => val.aurora(), + LightingEffectPreset::BubblingCauldron => val.bubbling_cauldron(), + LightingEffectPreset::CandyCane => val.candy_cane(), + LightingEffectPreset::Christmas => val.christmas(), + LightingEffectPreset::Flicker => val.flicker(), + LightingEffectPreset::GrandmasChristmasLights => val.grandmas_christmas_lights(), + LightingEffectPreset::Hanukkah => val.hanukkah(), + LightingEffectPreset::HauntedMansion => val.haunted_mansion(), + LightingEffectPreset::Icicle => val.icicle(), + LightingEffectPreset::Lightning => val.lightning(), + LightingEffectPreset::Ocean => val.ocean(), + LightingEffectPreset::Rainbow => val.rainbow(), + LightingEffectPreset::Raindrop => val.raindrop(), + LightingEffectPreset::Spring => val.spring(), + LightingEffectPreset::Sunrise => val.sunrise(), + LightingEffectPreset::Sunset => val.sunset(), + LightingEffectPreset::Valentines => val.valentines(), + } + } +} + +impl LightingEffectPreset { + fn aurora(self) -> LightingEffect { + LightingEffect::new( + "Aurora", + LightingEffectType::Sequence, + false, + true, + 100, + vec![ + [120, 100, 100], + [240, 100, 100], + [260, 100, 100], + [280, 100, 100], + ], + ) + .with_id("TapoStrip_1MClvV18i15Jq3bvJVf0eP") + .with_direction(4) + .with_duration(0) + .with_expansion_strategy(1) + .with_repeat_times(0) + .with_segments(vec![0]) + .with_sequence(vec![ + [120, 100, 100], + [240, 100, 100], + [260, 100, 100], + [280, 100, 100], + ]) + .with_spread(7) + .with_transition(1500) + } + + fn bubbling_cauldron(self) -> LightingEffect { + LightingEffect::new( + "Bubbling Cauldron", + LightingEffectType::Random, + false, + true, + 100, + vec![[100, 100, 100], [270, 100, 100]], + ) + .with_id("TapoStrip_6DlumDwO2NdfHppy50vJtu") + .with_backgrounds(vec![[270, 40, 50]]) + .with_brightness_range([50, 100]) + .with_init_states(vec![[270, 100, 100]]) + .with_duration(0) + .with_expansion_strategy(1) + .with_fade_off(1000) + .with_hue_range([100, 270]) + .with_random_seed(24) + .with_saturation_range([80, 100]) + .with_segments(vec![0]) + .with_transition(200) + } + + fn candy_cane(self) -> LightingEffect { + LightingEffect::new( + "Candy Cane", + LightingEffectType::Sequence, + false, + true, + 100, + vec![[0, 0, 100], [360, 81, 100]], + ) + .with_id("TapoStrip_6Dy0Nc45vlhFPEzG021Pe9") + .with_direction(1) + .with_duration(700) + .with_expansion_strategy(1) + .with_repeat_times(0) + .with_segments(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) + .with_sequence(vec![ + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + ]) + .with_spread(1) + .with_transition(500) + } + + fn christmas(self) -> LightingEffect { + LightingEffect::new( + "Christmas", + LightingEffectType::Random, + false, + true, + 100, + vec![[136, 98, 100], [350, 97, 100]], + ) + .with_id("TapoStrip_5zkiG6avJ1IbhjiZbRlWvh") + .with_backgrounds(vec![ + [136, 98, 75], + [136, 0, 0], + [350, 0, 100], + [350, 97, 94], + ]) + .with_brightness_range([50, 100]) + .with_init_states(vec![[136, 0, 100]]) + .with_duration(5000) + .with_expansion_strategy(1) + .with_fade_off(2000) + .with_hue_range([136, 146]) + .with_random_seed(100) + .with_saturation_range([90, 100]) + .with_segments(vec![0]) + .with_transition(0) + } + + fn flicker(self) -> LightingEffect { + LightingEffect::new( + "Flicker", + LightingEffectType::Random, + false, + true, + 100, + vec![[30, 81, 100], [40, 100, 100]], + ) + .with_id("TapoStrip_4HVKmMc6vEzjm36jXaGwMs") + .with_brightness_range([50, 100]) + .with_init_states(vec![[30, 81, 80]]) + .with_duration(0) + .with_expansion_strategy(1) + .with_hue_range([30, 40]) + .with_saturation_range([100, 100]) + .with_segments(vec![1]) + .with_transition(0) + .with_transition_range([375, 500]) + } + + fn grandmas_christmas_lights(self) -> LightingEffect { + LightingEffect::new( + "Grandma's Christmas Lights", + LightingEffectType::Sequence, + false, + true, + 100, + vec![ + [30, 100, 100], + [240, 100, 100], + [130, 100, 100], + [0, 100, 100], + ], + ) + .with_id("TapoStrip_3Gk6CmXOXbjCiwz9iD543C") + .with_direction(1) + .with_duration(5000) + .with_expansion_strategy(1) + .with_repeat_times(0) + .with_segments(vec![0]) + .with_sequence(vec![ + [30, 100, 100], + [30, 0, 0], + [30, 0, 0], + [240, 100, 100], + [240, 0, 0], + [240, 0, 0], + [240, 0, 100], + [240, 0, 0], + [240, 0, 0], + [130, 100, 100], + [130, 0, 0], + [130, 0, 0], + [0, 100, 100], + [0, 0, 0], + [0, 0, 0], + ]) + .with_spread(1) + .with_transition(100) + } + + fn hanukkah(self) -> LightingEffect { + LightingEffect::new( + "Hanukkah", + LightingEffectType::Random, + false, + true, + 100, + vec![[200, 100, 100]], + ) + .with_id("TapoStrip_2YTk4wramLKv5XZ9KFDVYm") + .with_brightness_range([50, 100]) + .with_init_states(vec![[35, 81, 80]]) + .with_duration(1500) + .with_expansion_strategy(1) + .with_hue_range([200, 210]) + .with_saturation_range([0, 100]) + .with_segments(vec![1]) + .with_transition(0) + .with_transition_range([400, 500]) + } + + fn haunted_mansion(self) -> LightingEffect { + LightingEffect::new( + "Haunted Mansion", + LightingEffectType::Random, + false, + true, + 100, + vec![[45, 10, 100]], + ) + .with_id("TapoStrip_4rJ6JwC7I9st3tQ8j4lwlI") + .with_backgrounds(vec![[45, 10, 100]]) + .with_brightness_range([0, 80]) + .with_init_states(vec![[45, 10, 100]]) + .with_duration(0) + .with_expansion_strategy(2) + .with_fade_off(200) + .with_hue_range([45, 45]) + .with_random_seed(1) + .with_saturation_range([10, 10]) + .with_segments(vec![80]) + .with_transition(0) + .with_transition_range([50, 1500]) + } + + fn icicle(self) -> LightingEffect { + LightingEffect::new( + "Icicle", + LightingEffectType::Sequence, + false, + true, + 100, + vec![[190, 100, 100]], + ) + .with_id("TapoStrip_7UcYLeJbiaxVIXCxr21tpx") + .with_direction(4) + .with_duration(0) + .with_expansion_strategy(1) + .with_repeat_times(0) + .with_segments(vec![0]) + .with_sequence(vec![ + [190, 100, 70], + [190, 100, 70], + [190, 30, 50], + [190, 100, 70], + [190, 100, 70], + ]) + .with_spread(3) + .with_transition(400) + } + + fn lightning(self) -> LightingEffect { + LightingEffect::new( + "Lightning", + LightingEffectType::Random, + false, + true, + 100, + vec![[210, 10, 100], [200, 50, 100], [200, 100, 100]], + ) + .with_id("TapoStrip_7OGzfSfnOdhoO2ri4gOHWn") + .with_backgrounds(vec![ + [200, 100, 100], + [200, 50, 10], + [210, 10, 50], + [240, 10, 0], + ]) + .with_brightness_range([90, 100]) + .with_init_states(vec![[240, 30, 100]]) + .with_duration(0) + .with_expansion_strategy(1) + .with_fade_off(150) + .with_hue_range([240, 240]) + .with_random_seed(600) + .with_saturation_range([10, 11]) + .with_segments(vec![7, 20, 23, 32, 34, 35, 49, 65, 66, 74, 80]) + .with_transition(50) + } + + fn ocean(self) -> LightingEffect { + LightingEffect::new( + "Ocean", + LightingEffectType::Sequence, + false, + true, + 100, + vec![[198, 84, 100]], + ) + .with_id("TapoStrip_0fOleCdwSgR0nfjkReeYfw") + .with_direction(3) + .with_duration(0) + .with_expansion_strategy(1) + .with_repeat_times(0) + .with_segments(vec![0]) + .with_sequence(vec![[198, 84, 30], [198, 70, 30], [198, 10, 30]]) + .with_spread(16) + .with_transition(2000) + } + + fn rainbow(self) -> LightingEffect { + LightingEffect::new( + "Rainbow", + LightingEffectType::Sequence, + false, + true, + 100, + vec![ + [0, 100, 100], + [100, 100, 100], + [200, 100, 100], + [300, 100, 100], + ], + ) + .with_id("TapoStrip_7CC5y4lsL8pETYvmz7UOpQ") + .with_direction(1) + .with_duration(0) + .with_expansion_strategy(1) + .with_repeat_times(0) + .with_segments(vec![0]) + .with_sequence(vec![ + [0, 100, 100], + [100, 100, 100], + [200, 100, 100], + [300, 100, 100], + ]) + .with_spread(12) + .with_transition(1500) + } + + fn raindrop(self) -> LightingEffect { + LightingEffect::new( + "Raindrop", + LightingEffectType::Random, + false, + true, + 100, + vec![[200, 10, 100], [200, 20, 100]], + ) + .with_id("TapoStrip_1t2nWlTBkV8KXBZ0TWvBjs") + .with_backgrounds(vec![[200, 40, 0]]) + .with_brightness_range([10, 30]) + .with_init_states(vec![[200, 40, 100]]) + .with_duration(0) + .with_expansion_strategy(1) + .with_fade_off(1000) + .with_hue_range([200, 200]) + .with_random_seed(24) + .with_saturation_range([10, 20]) + .with_segments(vec![0]) + .with_transition(1000) + } + + fn spring(self) -> LightingEffect { + LightingEffect::new( + "Spring", + LightingEffectType::Random, + false, + true, + 100, + vec![[0, 30, 100], [130, 100, 100]], + ) + .with_id("TapoStrip_1nL6GqZ5soOxj71YDJOlZL") + .with_backgrounds(vec![[130, 100, 40]]) + .with_brightness_range([90, 100]) + .with_init_states(vec![[80, 30, 100]]) + .with_duration(600) + .with_expansion_strategy(1) + .with_fade_off(1000) + .with_hue_range([0, 90]) + .with_random_seed(20) + .with_saturation_range([30, 100]) + .with_segments(vec![0]) + .with_transition(0) + .with_transition_range([2000, 6000]) + } + + fn sunrise(self) -> LightingEffect { + LightingEffect::new( + "Sunrise", + LightingEffectType::Pulse, + false, + true, + 100, + vec![[30, 0, 100], [30, 95, 100], [0, 100, 100]], + ) + .with_id("TapoStrip_1OVSyXIsDxrt4j7OxyRvqi") + .with_direction(1) + .with_duration(600) + .with_expansion_strategy(2) + .with_repeat_times(1) + .with_segments(vec![0]) + .with_sequence(vec![ + [0, 100, 5], + [0, 100, 5], + [10, 100, 6], + [15, 100, 7], + [20, 100, 8], + [20, 100, 10], + [30, 100, 12], + [30, 95, 15], + [30, 90, 20], + [30, 80, 25], + [30, 75, 30], + [30, 70, 40], + [30, 60, 50], + [30, 50, 60], + [30, 20, 70], + [30, 0, 100], + ]) + .with_spread(1) + .with_transition(60000) + .with_run_time(0) + } + + fn sunset(self) -> LightingEffect { + LightingEffect::new( + "Sunset", + LightingEffectType::Pulse, + false, + true, + 100, + vec![[0, 100, 100], [30, 95, 100], [30, 0, 100]], + ) + .with_id("TapoStrip_5NiN0Y8GAUD78p4neKk9EL") + .with_direction(1) + .with_duration(600) + .with_expansion_strategy(2) + .with_repeat_times(1) + .with_segments(vec![0]) + .with_sequence(vec![ + [30, 0, 100], + [30, 20, 100], + [30, 50, 99], + [30, 60, 98], + [30, 70, 97], + [30, 75, 95], + [30, 80, 93], + [30, 90, 90], + [30, 95, 85], + [30, 100, 80], + [20, 100, 70], + [20, 100, 60], + [15, 100, 50], + [10, 100, 40], + [0, 100, 30], + [0, 100, 0], + ]) + .with_spread(1) + .with_transition(60000) + .with_run_time(0) + } + + fn valentines(self) -> LightingEffect { + LightingEffect::new( + "Valentines", + LightingEffectType::Random, + false, + true, + 100, + vec![[340, 20, 100], [20, 50, 100], [0, 100, 100], [340, 40, 100]], + ) + .with_id("TapoStrip_2q1Vio9sSjHmaC7JS9d30l") + .with_backgrounds(vec![[340, 20, 50], [20, 50, 50], [0, 100, 50]]) + .with_brightness_range([90, 100]) + .with_init_states(vec![[340, 30, 100]]) + .with_duration(600) + .with_expansion_strategy(1) + .with_fade_off(3000) + .with_hue_range([340, 340]) + .with_random_seed(100) + .with_saturation_range([30, 40]) + .with_segments(vec![0]) + .with_transition(2000) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/trv.rs b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/trv.rs new file mode 100644 index 0000000..c2b52e2 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/set_device_info/trv.rs @@ -0,0 +1,90 @@ +use serde::Serialize; + +use crate::error::Error; + +use crate::responses::TemperatureUnitKE100; + +#[derive(Debug, Default, Serialize)] +pub(crate) struct TrvSetDeviceInfoParams { + #[serde(skip_serializing_if = "Option::is_none", rename = "target_temp")] + target_temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + frost_protection_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + child_protection: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "temp_offset")] + temperature_offset: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "min_temp")] + min_temperature: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "min_control_temp")] + min_control_temperature: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "max_control_temp")] + max_control_temperature: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "temp_unit")] + temperature_unit: Option, +} + +impl TrvSetDeviceInfoParams { + pub fn target_temperature( + mut self, + value: u8, + unit: TemperatureUnitKE100, + ) -> Result { + self.target_temperature = Some(value); + self.temperature_unit = Some(unit); + self.validate() + } + pub fn frost_protection_on(mut self, value: bool) -> Result { + self.frost_protection_on = Some(value); + self.validate() + } + pub fn child_protection(mut self, value: bool) -> Result { + self.child_protection = Some(value); + self.validate() + } + pub fn temperature_offset( + mut self, + value: i8, + unit: TemperatureUnitKE100, + ) -> Result { + self.temperature_offset = Some(value); + self.temperature_unit = Some(unit); + self.validate() + } + pub fn min_control_temperature( + mut self, + value: u8, + unit: TemperatureUnitKE100, + ) -> Result { + self.min_control_temperature = Some(value); + self.temperature_unit = Some(unit); + self.validate() + } + pub fn max_control_temperature( + mut self, + value: u8, + unit: TemperatureUnitKE100, + ) -> Result { + self.max_control_temperature = Some(value); + self.temperature_unit = Some(unit); + self.validate() + } +} + +impl TrvSetDeviceInfoParams { + pub(crate) fn new() -> Self { + Self::default() + } + + pub fn validate(self) -> Result { + if let Some(temperature_offset) = self.temperature_offset + && !(-10..=10).contains(&temperature_offset) + { + return Err(Error::Validation { + field: "temperature_offset".to_string(), + message: "Must be between -10 and 10".to_string(), + }); + } + Ok(self) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/requests/tapo_request.rs b/agents/tapo/tapo-fork/tapo/src/requests/tapo_request.rs new file mode 100644 index 0000000..8934f6e --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/requests/tapo_request.rs @@ -0,0 +1,87 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Serialize; + +use super::{ + ControlChildParams, DeviceRebootParams, GetChildDeviceListParams, GetEnergyDataParams, + GetPowerDataParams, GetRulesParams, GetTriggerLogsParams, HandshakeParams, LightingEffect, + LoginDeviceParams, MultipleRequestParams, PlayAlarmParams, SecurePassthroughParams, +}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "method")] +pub(crate) enum TapoRequest { + #[serde(rename = "component_nego")] + ComponentNegotiation(TapoParams), + Handshake(TapoParams), + LoginDevice(TapoParams), + #[serde(rename = "securePassthrough")] + SecurePassthrough(TapoParams), + SetDeviceInfo(Box>), + SetLightingEffect(Box>), + DeviceReset(TapoParams), + DeviceReboot(TapoParams), + GetDeviceInfo(TapoParams), + GetDeviceUsage(TapoParams), + GetEnergyUsage(TapoParams), + GetCurrentPower(TapoParams), + GetEnergyData(TapoParams), + GetPowerData(TapoParams), + GetChildDeviceList(TapoParams), + GetChildDeviceComponentList(TapoParams), + ControlChild(Box>), + // Child requests + #[serde(rename = "multipleRequest")] + MultipleRequest(Box>), + GetTriggerLogs(Box>), + #[serde(rename = "get_temp_humidity_records")] + GetTemperatureHumidityRecords(Box>), + PlayAlarm(TapoParams), + StopAlarm(TapoParams), + #[serde(rename = "get_support_alarm_type_list")] + GetSupportedAlarmTypeList(TapoParams), + // Schedule and countdown APIs + #[serde(rename = "get_countdown_rules")] + GetCountdownRules(TapoParams), + #[serde(rename = "get_schedule_rules")] + GetScheduleRules(TapoParams), + #[serde(rename = "get_next_event")] + GetNextEvent(TapoParams), + #[serde(rename = "get_antitheft_rules")] + GetAntitheftRules(TapoParams), +} + +#[derive(Debug, Serialize)] +pub(crate) struct EmptyParams; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TapoParams { + params: T, + #[serde(skip_serializing_if = "Option::is_none")] + request_time_milis: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "terminalUUID")] + terminal_uuid: Option, +} + +impl TapoParams { + pub fn new(params: T) -> Self { + Self { + params, + request_time_milis: None, + terminal_uuid: None, + } + } + + pub fn set_request_time_mils(mut self) -> anyhow::Result { + self.request_time_milis + .replace(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64); + Ok(self) + } + + pub fn set_terminal_uuid(mut self, terminal_uuid: &str) -> Self { + self.terminal_uuid.replace(terminal_uuid.to_string()); + self + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses.rs b/agents/tapo/tapo-fork/tapo/src/responses.rs new file mode 100644 index 0000000..1057879 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses.rs @@ -0,0 +1,42 @@ +//! Tapo response objects. + +mod child_device_list_hub_result; +mod child_device_list_power_strip_result; +mod control_child_result; +mod current_power_result; +mod decodable_result_ext; +mod device_info_result; +mod device_usage_energy_monitoring_result; +mod device_usage_result; +mod energy_data_result; +mod energy_usage_result; +mod handshake_result; +mod power_data_result; +mod schedule_rules_result; +mod supported_alarm_type_list_result; +mod tapo_response; +mod tapo_result; +mod token_result; +mod trigger_logs_result; + +pub use crate::requests::{LightingEffect, LightingEffectType}; + +pub use child_device_list_hub_result::*; +pub use child_device_list_power_strip_result::*; +pub use current_power_result::*; +pub use device_info_result::*; +pub use device_usage_energy_monitoring_result::*; +pub use device_usage_result::*; +pub use energy_data_result::*; +pub use energy_usage_result::*; +pub use power_data_result::*; +pub use schedule_rules_result::*; +pub use trigger_logs_result::*; + +pub(crate) use control_child_result::*; +pub(crate) use decodable_result_ext::*; +pub(crate) use handshake_result::*; +pub(crate) use supported_alarm_type_list_result::*; +pub(crate) use tapo_response::*; +pub(crate) use tapo_result::*; +pub(crate) use token_result::*; diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result.rs new file mode 100644 index 0000000..787ecc5 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result.rs @@ -0,0 +1,103 @@ +mod ke100_result; +mod s200b_result; +mod t100_result; +mod t110_result; +mod t300_result; +mod t31x_result; + +pub use ke100_result::*; +pub use s200b_result::*; +pub use t31x_result::*; +pub use t100_result::*; +pub use t110_result::*; +pub use t300_result::*; + +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, TapoResponseExt}; + +/// Hub child device list result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ChildDeviceListHubResult { + /// Hub child devices + #[serde(rename = "child_device_list")] + pub devices: Vec, +} + +impl DecodableResultExt for ChildDeviceListHubResult { + fn decode(self) -> Result { + Ok(ChildDeviceListHubResult { + devices: self + .devices + .into_iter() + .map(|d| d.decode()) + .collect::, _>>()?, + }) + } +} + +impl TapoResponseExt for ChildDeviceListHubResult {} + +/// Device status. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum Status { + Online, + Offline, +} + +/// Hub child device result. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "model")] +pub enum ChildDeviceHubResult { + /// KE100 thermostatic radiator valve (TRV). + KE100(Box), + /// S200B button switch. + S200B(Box), + /// T100 motion sensor. + T100(Box), + /// T110 contact sensor. + T110(Box), + /// T300 water sensor. + T300(Box), + /// T310 temperature and humidity sensor. + T310(Box), + /// T315 temperature and humidity sensor. + T315(Box), + /// Catch-all for currently unsupported devices. + /// Please open an issue if you need support for a new device. + #[serde(other)] + Other, +} + +impl DecodableResultExt for ChildDeviceHubResult { + fn decode(self) -> Result { + match self { + ChildDeviceHubResult::KE100(device) => { + Ok(ChildDeviceHubResult::KE100(Box::new(device.decode()?))) + } + ChildDeviceHubResult::S200B(device) => { + Ok(ChildDeviceHubResult::S200B(Box::new(device.decode()?))) + } + ChildDeviceHubResult::T100(device) => { + Ok(ChildDeviceHubResult::T100(Box::new(device.decode()?))) + } + ChildDeviceHubResult::T110(device) => { + Ok(ChildDeviceHubResult::T110(Box::new(device.decode()?))) + } + ChildDeviceHubResult::T300(device) => { + Ok(ChildDeviceHubResult::T300(Box::new(device.decode()?))) + } + ChildDeviceHubResult::T310(device) => { + Ok(ChildDeviceHubResult::T310(Box::new(device.decode()?))) + } + ChildDeviceHubResult::T315(device) => { + Ok(ChildDeviceHubResult::T315(Box::new(device.decode()?))) + } + ChildDeviceHubResult::Other => Ok(ChildDeviceHubResult::Other), + } + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/ke100_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/ke100_result.rs new file mode 100644 index 0000000..c84fce4 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/ke100_result.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, Status, TapoResponseExt, decode_value}; + +/// Temperature unit for KE100 devices. +/// Currently *Celsius* is the only unit supported by KE100. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum TemperatureUnitKE100 { + Celsius, +} + +/// Device info of Tapo KE100 thermostatic radiator valve (TRV). +/// +/// Specific properties: `temperature_unit`, `current_temperature`, `target_temperature`, +/// `min_control_temperature`, `max_control_temperature`, `temperature_offset`, +/// `child_protection_on`, `frost_protection_on`, `location`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct KE100Result { + // Common properties to all Hub child devices. + pub at_low_battery: bool, + pub avatar: String, + pub bind_count: u32, + pub category: String, + pub device_id: String, + pub fw_ver: String, + pub hw_id: String, + pub hw_ver: String, + pub jamming_rssi: i16, + pub jamming_signal_level: u8, + pub mac: String, + pub nickname: String, + pub oem_id: String, + pub parent_device_id: String, + pub region: String, + pub rssi: i16, + pub signal_level: u8, + pub specs: String, + pub status: Status, + pub r#type: String, + // Specific properties to this device. + #[serde(rename = "child_protection")] + pub child_protection_on: bool, + #[serde(rename = "current_temp")] + pub current_temperature: f32, + pub frost_protection_on: bool, + pub location: String, + #[serde(rename = "max_control_temp")] + pub max_control_temperature: u8, + #[serde(rename = "min_control_temp")] + pub min_control_temperature: u8, + #[serde(rename = "target_temp")] + pub target_temperature: f32, + #[serde(rename = "temp_offset")] + pub temperature_offset: i8, + #[serde(rename = "temp_unit")] + pub temperature_unit: TemperatureUnitKE100, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl KE100Result { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for KE100Result {} + +impl DecodableResultExt for KE100Result { + fn decode(mut self) -> Result { + self.nickname = decode_value(&self.nickname)?; + Ok(self) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/s200b_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/s200b_result.rs new file mode 100644 index 0000000..c2b0dbc --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/s200b_result.rs @@ -0,0 +1,119 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, Status, TapoResponseExt, decode_value}; + +/// Device info of Tapo S200B button switch. +/// +/// Specific properties: `report_interval`, `last_onboarding_timestamp`, `status_follow_edge`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct S200BResult { + // Common properties to all Hub child devices. + pub at_low_battery: bool, + pub avatar: String, + pub bind_count: u32, + pub category: String, + pub device_id: String, + pub fw_ver: String, + pub hw_id: String, + pub hw_ver: String, + pub jamming_rssi: i16, + pub jamming_signal_level: u8, + pub mac: String, + pub nickname: String, + pub oem_id: String, + pub parent_device_id: String, + pub region: String, + pub rssi: i16, + pub signal_level: u8, + pub specs: String, + pub status: Status, + pub r#type: String, + // Specific properties to this device. + #[serde(rename = "lastOnboardingTimestamp")] + pub last_onboarding_timestamp: u64, + /// The time in seconds between each report. + pub report_interval: u32, + pub status_follow_edge: bool, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl S200BResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for S200BResult {} + +impl DecodableResultExt for S200BResult { + fn decode(mut self) -> Result { + self.nickname = decode_value(&self.nickname)?; + Ok(self) + } +} + +/// S200B Rotation log params. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct S200BRotationParams { + #[serde(rename = "rotate_deg")] + pub rotation_degrees: i16, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl S200BRotationParams { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +/// S200B Log. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "event")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub enum S200BLog { + Rotation { + id: u64, + timestamp: u64, + params: S200BRotationParams, + }, + SingleClick { + id: u64, + timestamp: u64, + }, + DoubleClick { + id: u64, + timestamp: u64, + }, + LowBattery { + id: u64, + timestamp: u64, + }, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl S200BLog { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t100_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t100_result.rs new file mode 100644 index 0000000..a5cc448 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t100_result.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, Status, TapoResponseExt, decode_value}; + +/// Device info of Tapo T100 motion sensor. +/// +/// Specific properties: `detected`, `report_interval`, +/// `last_onboarding_timestamp`, `status_follow_edge`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct T100Result { + // Common properties to all Hub child devices. + pub at_low_battery: bool, + pub avatar: String, + pub bind_count: u32, + pub category: String, + pub device_id: String, + pub fw_ver: String, + pub hw_id: String, + pub hw_ver: String, + pub jamming_rssi: i16, + pub jamming_signal_level: u8, + pub mac: String, + pub nickname: String, + pub oem_id: String, + pub parent_device_id: String, + pub region: String, + pub rssi: i16, + pub signal_level: u8, + pub specs: String, + pub status: Status, + pub r#type: String, + // Specific properties to this device. + pub detected: bool, + #[serde(rename = "lastOnboardingTimestamp")] + pub last_onboarding_timestamp: u64, + /// The time in seconds between each report. + pub report_interval: u32, + pub status_follow_edge: bool, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl T100Result { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for T100Result {} + +impl DecodableResultExt for T100Result { + fn decode(mut self) -> Result { + self.nickname = decode_value(&self.nickname)?; + Ok(self) + } +} + +/// T100 Log. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "event")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub enum T100Log { + Motion { id: u64, timestamp: u64 }, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl T100Log { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t110_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t110_result.rs new file mode 100644 index 0000000..05642a6 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t110_result.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, Status, TapoResponseExt, decode_value}; + +/// Device info of Tapo T110 contact sensor. +/// +/// Specific properties: `open`, `report_interval`, +/// `last_onboarding_timestamp`,`status_follow_edge`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct T110Result { + // Common properties to all Hub child devices. + pub at_low_battery: bool, + pub avatar: String, + pub bind_count: u32, + pub category: String, + pub device_id: String, + pub fw_ver: String, + pub hw_id: String, + pub hw_ver: String, + pub jamming_rssi: i16, + pub jamming_signal_level: u8, + pub mac: String, + pub nickname: String, + pub oem_id: String, + pub parent_device_id: String, + pub region: String, + pub rssi: i16, + pub signal_level: u8, + pub specs: String, + pub status: Status, + pub r#type: String, + // Specific properties to this device. + #[serde(rename = "lastOnboardingTimestamp")] + pub last_onboarding_timestamp: u64, + pub open: bool, + /// The time in seconds between each report. + pub report_interval: u32, + pub status_follow_edge: bool, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl T110Result { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for T110Result {} + +impl DecodableResultExt for T110Result { + fn decode(mut self) -> Result { + self.nickname = decode_value(&self.nickname)?; + Ok(self) + } +} + +/// T110 Log. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "event")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub enum T110Log { + Close { + id: u64, + timestamp: u64, + }, + Open { + id: u64, + timestamp: u64, + }, + /// Fired when the sensor has been open for more than 1 minute. + KeepOpen { + id: u64, + timestamp: u64, + }, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl T110Log { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t300_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t300_result.rs new file mode 100644 index 0000000..6972641 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t300_result.rs @@ -0,0 +1,97 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, Status, TapoResponseExt, decode_value}; + +/// Water leak status. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum WaterLeakStatus { + Normal, + WaterDry, + WaterLeak, +} + +/// Device info of Tapo T300 water sensor. +/// +/// Specific properties: `in_alarm`, `water_leak_status`, `report_interval`, +/// `last_onboarding_timestamp`, `status_follow_edge`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct T300Result { + // Common properties to all Hub child devices. + pub at_low_battery: bool, + pub avatar: String, + pub bind_count: u32, + pub category: String, + pub device_id: String, + pub fw_ver: String, + pub hw_id: String, + pub hw_ver: String, + pub jamming_rssi: i16, + pub jamming_signal_level: u8, + pub mac: String, + pub nickname: String, + pub oem_id: String, + pub parent_device_id: String, + pub region: String, + pub rssi: i16, + pub signal_level: u8, + pub specs: String, + pub status: Status, + pub r#type: String, + // Specific properties to this device. + pub in_alarm: bool, + #[serde(rename = "lastOnboardingTimestamp")] + pub last_onboarding_timestamp: u64, + /// The time in seconds between each report. + pub report_interval: u32, + pub status_follow_edge: bool, + pub water_leak_status: WaterLeakStatus, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl T300Result { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for T300Result {} + +impl DecodableResultExt for T300Result { + fn decode(mut self) -> Result { + self.nickname = decode_value(&self.nickname)?; + Ok(self) + } +} + +/// T300 Log. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "event")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub enum T300Log { + WaterDry { id: u64, timestamp: u64 }, + WaterLeak { id: u64, timestamp: u64 }, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl T300Log { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t31x_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t31x_result.rs new file mode 100644 index 0000000..d07d41e --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_hub_result/t31x_result.rs @@ -0,0 +1,402 @@ +use chrono::{DateTime, Duration, Timelike, Utc}; +use itertools::izip; +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, Status, TapoResponseExt, decode_value}; + +/// Temperature unit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum TemperatureUnit { + Celsius, + Fahrenheit, +} + +/// Device info of Tapo T310 and T315 temperature and humidity sensors. +/// +/// Specific properties: `current_temperature`, `temperature_unit`, +/// `current_temperature_exception`, `current_humidity`, `current_humidity_exception`, +/// `report_interval`, `last_onboarding_timestamp`, `status_follow_edge`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct T31XResult { + // Common properties to all Hub child devices. + pub at_low_battery: bool, + pub avatar: String, + pub bind_count: u32, + pub category: String, + pub device_id: String, + pub fw_ver: String, + pub hw_id: String, + pub hw_ver: String, + pub jamming_rssi: i16, + pub jamming_signal_level: u8, + pub mac: String, + pub nickname: String, + pub oem_id: String, + pub parent_device_id: String, + pub region: String, + pub rssi: i16, + pub signal_level: u8, + pub specs: String, + pub status: Status, + pub r#type: String, + // Specific properties to this device. + /// This value will be `0` when the current humidity is within the comfort zone. + /// When the current humidity value falls outside the comfort zone, this value + /// will be the difference between the current humidity and the lower or upper bound of the comfort zone. + pub current_humidity_exception: i8, + pub current_humidity: u8, + /// This value will be `0.0` when the current temperature is within the comfort zone. + /// When the current temperature value falls outside the comfort zone, this value + /// will be the difference between the current temperature and the lower or upper bound of the comfort zone. + #[serde(rename = "current_temp_exception")] + pub current_temperature_exception: f32, + #[serde(rename = "current_temp")] + pub current_temperature: f32, + #[serde(rename = "lastOnboardingTimestamp")] + pub last_onboarding_timestamp: u64, + /// The time in seconds between each report. + pub report_interval: u32, + pub status_follow_edge: bool, + #[serde(rename = "temp_unit")] + pub temperature_unit: TemperatureUnit, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl T31XResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for T31XResult {} + +impl DecodableResultExt for T31XResult { + fn decode(mut self) -> Result { + self.nickname = decode_value(&self.nickname)?; + Ok(self) + } +} + +#[derive(Debug, Deserialize)] +pub(crate) struct TemperatureHumidityRecordsRaw { + pub local_time: i64, + pub past24h_humidity_exception: Vec, + pub past24h_humidity: Vec, + pub past24h_temp_exception: Vec, + pub past24h_temp: Vec, + pub temp_unit: TemperatureUnit, +} + +impl TapoResponseExt for TemperatureHumidityRecordsRaw {} + +/// Temperature and Humidity record as an average over a 15 minute interval. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct TemperatureHumidityRecord { + /// Record's DateTime in UTC. + pub datetime: DateTime, + /// This value will be `0` when the current humidity is within the comfort zone. + /// When the current humidity value falls outside the comfort zone, this value + /// will be the difference between the current humidity and the lower or upper bound of the comfort zone. + pub humidity_exception: i8, + pub humidity: u8, + /// This value will be `0.0` when the current temperature is within the comfort zone. + /// When the current temperature value falls outside the comfort zone, this value + /// will be the difference between the current temperature and the lower or upper bound of the comfort zone. + pub temperature_exception: f32, + pub temperature: f32, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl TemperatureHumidityRecord { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +/// Temperature and Humidity records for the last 24 hours at 15 minute intervals. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct TemperatureHumidityRecords { + /// The datetime in UTC of when this response was generated. + pub datetime: DateTime, + pub records: Vec, + pub temperature_unit: TemperatureUnit, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl TemperatureHumidityRecords { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TryFrom for TemperatureHumidityRecords { + type Error = anyhow::Error; + + fn try_from(raw: TemperatureHumidityRecordsRaw) -> Result { + let datetime = DateTime::from_timestamp(raw.local_time, 0).unwrap_or_default(); + + let interval_minute = if datetime.minute() >= 45 { + 45 + } else if datetime.minute() >= 30 { + 30 + } else if datetime.minute() >= 15 { + 15 + } else { + 0 + }; + + let mut interval_time = datetime + .with_minute(interval_minute) + .unwrap_or_default() + .with_second(0) + .unwrap_or_default(); + + let mut records = Vec::with_capacity(raw.past24h_temp.len()); + + let iter = izip!( + raw.past24h_humidity_exception.into_iter(), + raw.past24h_humidity.into_iter(), + raw.past24h_temp_exception.into_iter(), + raw.past24h_temp.into_iter(), + ) + .rev(); + + for (humidity_exception, humidity, temperature_exception, temperature) in iter { + if temperature != -1000 + && temperature_exception != -1000 + && humidity != -1000 + && humidity_exception != -1000 + { + records.push(TemperatureHumidityRecord { + humidity_exception: humidity_exception as i8, + humidity: humidity as u8, + datetime: interval_time, + temperature_exception: temperature_exception as f32 / 10.0, + temperature: temperature as f32 / 10.0, + }); + } + + interval_time = interval_time + .checked_sub_signed(Duration::try_minutes(15).unwrap()) + .ok_or_else(|| anyhow::anyhow!("Failed to subtract from interval"))?; + } + + records.reverse(); + + Ok(Self { + datetime, + temperature_unit: raw.temp_unit, + records, + }) + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDateTime; + + use super::*; + + #[test] + fn test_temperature_humidity_records_parse() { + let raw = TemperatureHumidityRecordsRaw { + local_time: 1685371944, + past24h_humidity_exception: vec![0, 0, 0, 0, 0, 0], + past24h_humidity: vec![49, 50, 50, 55, 53, 52], + past24h_temp_exception: vec![0, 0, 0, 0, 0, 0], + past24h_temp: vec![196, 195, 194, 162, 164, 165], + temp_unit: TemperatureUnit::Celsius, + }; + + let parsed = TemperatureHumidityRecords::try_from(raw).unwrap(); + + assert_eq!( + parsed.datetime, + NaiveDateTime::parse_from_str("2023-05-29 14:52:24", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc() + ); + assert_eq!(parsed.temperature_unit, TemperatureUnit::Celsius); + assert_eq!(parsed.records.len(), 6); + assert_eq!( + parsed.records[0], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 49, + datetime: NaiveDateTime::parse_from_str("2023-05-29 13:30:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 19.6, + } + ); + assert_eq!( + parsed.records[1], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 50, + datetime: NaiveDateTime::parse_from_str("2023-05-29 13:45:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 19.5, + } + ); + assert_eq!( + parsed.records[2], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 50, + datetime: NaiveDateTime::parse_from_str("2023-05-29 14:00:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 19.4, + } + ); + assert_eq!( + parsed.records[3], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 55, + datetime: NaiveDateTime::parse_from_str("2023-05-29 14:15:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 16.2, + } + ); + assert_eq!( + parsed.records[4], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 53, + datetime: NaiveDateTime::parse_from_str("2023-05-29 14:30:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 16.4, + } + ); + assert_eq!( + parsed.records[5], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 52, + datetime: NaiveDateTime::parse_from_str("2023-05-29 14:45:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 16.5, + } + ); + } + + #[test] + fn test_temperature_humidity_records_parse_in_progress() { + let raw = TemperatureHumidityRecordsRaw { + local_time: 1685371944, + past24h_humidity_exception: vec![0, 0, 0, 0, 0, -1000], + past24h_humidity: vec![49, 50, 50, 55, 53, -1000], + past24h_temp_exception: vec![0, 0, 0, 0, 0, -1000], + past24h_temp: vec![196, 195, 194, 162, 164, -1000], + temp_unit: TemperatureUnit::Celsius, + }; + + let parsed = TemperatureHumidityRecords::try_from(raw).unwrap(); + + assert_eq!( + parsed.datetime, + NaiveDateTime::parse_from_str("2023-05-29 14:52:24", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc() + ); + assert_eq!(parsed.temperature_unit, TemperatureUnit::Celsius); + assert_eq!(parsed.records.len(), 5); + assert_eq!( + parsed.records[0], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 49, + datetime: NaiveDateTime::parse_from_str("2023-05-29 13:30:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 19.6, + } + ); + assert_eq!( + parsed.records[1], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 50, + datetime: NaiveDateTime::parse_from_str("2023-05-29 13:45:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 19.5, + } + ); + assert_eq!( + parsed.records[2], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 50, + datetime: NaiveDateTime::parse_from_str("2023-05-29 14:00:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 19.4, + } + ); + assert_eq!( + parsed.records[3], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 55, + datetime: NaiveDateTime::parse_from_str("2023-05-29 14:15:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 16.2, + } + ); + assert_eq!( + parsed.records[4], + TemperatureHumidityRecord { + humidity_exception: 0, + humidity: 53, + datetime: NaiveDateTime::parse_from_str("2023-05-29 14:30:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_utc(), + temperature_exception: 0.0, + temperature: 16.4, + } + ); + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result.rs new file mode 100644 index 0000000..2087b55 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result.rs @@ -0,0 +1,7 @@ +mod auto_off_status; +mod power_strip_plug_energy_monitoring_result; +mod power_strip_plug_result; + +pub use auto_off_status::*; +pub use power_strip_plug_energy_monitoring_result::*; +pub use power_strip_plug_result::*; diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result/auto_off_status.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result/auto_off_status.rs new file mode 100644 index 0000000..3ab886f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result/auto_off_status.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +/// Auto Off Status. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum AutoOffStatus { + On, + Off, +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result/power_strip_plug_energy_monitoring_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result/power_strip_plug_energy_monitoring_result.rs new file mode 100644 index 0000000..9de59b8 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result/power_strip_plug_energy_monitoring_result.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{ + ChargingStatus, DecodableResultExt, DefaultPlugState, OvercurrentStatus, OverheatStatus, + PowerProtectionStatus, TapoResponseExt, decode_value, +}; + +use super::AutoOffStatus; + +/// Power Strip child device list result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ChildDeviceListPowerStripEnergyMonitoringResult { + /// Power Strip child devices + #[serde(rename = "child_device_list")] + pub plugs: Vec, +} + +impl DecodableResultExt for ChildDeviceListPowerStripEnergyMonitoringResult { + fn decode(self) -> Result { + Ok(ChildDeviceListPowerStripEnergyMonitoringResult { + plugs: self + .plugs + .into_iter() + .map(|d| d.decode()) + .collect::, _>>()?, + }) + } +} + +impl TapoResponseExt for ChildDeviceListPowerStripEnergyMonitoringResult {} + +/// P304M and P316M power strip child plugs. +/// +/// Specific properties: `auto_off_remain_time`, `auto_off_status`, +/// `bind_count`, `default_states`, `charging_status`, `is_usb`, +/// `overcurrent_status`, `overheat_status`, `position`, +/// `power_protection_status`, `slot_number`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct PowerStripPlugEnergyMonitoringResult { + pub auto_off_remain_time: u64, + pub auto_off_status: AutoOffStatus, + pub avatar: String, + pub bind_count: u8, + pub category: String, + pub default_states: DefaultPlugState, + pub charging_status: ChargingStatus, + pub device_id: String, + pub device_on: bool, + pub fw_id: String, + pub fw_ver: String, + pub has_set_location_info: bool, + pub hw_id: String, + pub hw_ver: String, + pub is_usb: bool, + pub latitude: Option, + pub longitude: Option, + pub mac: String, + pub model: String, + pub nickname: String, + pub oem_id: String, + /// The time in seconds this device has been ON since the last state change (On/Off). + pub on_time: u64, + pub original_device_id: String, + pub overcurrent_status: OvercurrentStatus, + pub overheat_status: Option, + pub position: u8, + pub power_protection_status: PowerProtectionStatus, + pub region: Option, + pub slot_number: u8, + pub status_follow_edge: bool, + pub r#type: String, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl PowerStripPlugEnergyMonitoringResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for PowerStripPlugEnergyMonitoringResult {} + +impl DecodableResultExt for PowerStripPlugEnergyMonitoringResult { + fn decode(mut self) -> Result { + self.nickname = decode_value(&self.nickname)?; + Ok(self) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result/power_strip_plug_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result/power_strip_plug_result.rs new file mode 100644 index 0000000..2d6b5c2 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/child_device_list_power_strip_result/power_strip_plug_result.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{ + DecodableResultExt, DefaultPlugState, OverheatStatus, TapoResponseExt, decode_value, +}; + +use super::AutoOffStatus; + +/// Power Strip child device list result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ChildDeviceListPowerStripResult { + /// Power Strip child devices + #[serde(rename = "child_device_list")] + pub plugs: Vec, +} + +impl DecodableResultExt for ChildDeviceListPowerStripResult { + fn decode(self) -> Result { + Ok(ChildDeviceListPowerStripResult { + plugs: self + .plugs + .into_iter() + .map(|d| d.decode()) + .collect::, _>>()?, + }) + } +} + +impl TapoResponseExt for ChildDeviceListPowerStripResult {} + +/// P300 and P306 power strip child plugs. +/// +/// Specific properties: `auto_off_remain_time`, `auto_off_status`, +/// `bind_count`, `default_states`, `overheat_status`, `position`, `slot_number`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct PowerStripPlugResult { + pub auto_off_remain_time: u64, + pub auto_off_status: AutoOffStatus, + pub avatar: String, + pub bind_count: u8, + pub category: String, + pub default_states: DefaultPlugState, + pub device_id: String, + pub device_on: bool, + pub fw_id: String, + pub fw_ver: String, + pub has_set_location_info: bool, + pub hw_id: String, + pub hw_ver: String, + pub latitude: Option, + pub longitude: Option, + pub mac: String, + pub model: String, + pub nickname: String, + pub oem_id: String, + /// The time in seconds this device has been ON since the last state change (On/Off). + pub on_time: u64, + pub original_device_id: String, + pub overheat_status: Option, + pub position: u8, + pub region: Option, + pub slot_number: u8, + pub status_follow_edge: bool, + pub r#type: String, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl PowerStripPlugResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for PowerStripPlugResult {} + +impl DecodableResultExt for PowerStripPlugResult { + fn decode(mut self) -> Result { + self.nickname = decode_value(&self.nickname)?; + Ok(self) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/control_child_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/control_child_result.rs new file mode 100644 index 0000000..f5c661c --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/control_child_result.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +use super::TapoResponseExt; + +#[derive(Debug, Deserialize)] +pub(crate) struct ControlChildResult { + #[serde(rename = "responseData")] + pub response_data: T, +} + +impl TapoResponseExt for ControlChildResult {} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/current_power_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/current_power_result.rs new file mode 100644 index 0000000..ce656ce --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/current_power_result.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +use crate::responses::TapoResponseExt; + +/// Contains the current power reading of the device. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +pub struct CurrentPowerResult { + /// Current power in Watts (W). + pub current_power: u64, +} +impl TapoResponseExt for CurrentPowerResult {} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl CurrentPowerResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/decodable_result_ext.rs b/agents/tapo/tapo-fork/tapo/src/responses/decodable_result_ext.rs new file mode 100644 index 0000000..0e8b9d4 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/decodable_result_ext.rs @@ -0,0 +1,23 @@ +use base64::{Engine as _, engine::general_purpose}; + +use crate::error::Error; + +/// Implemented by all Device Info Result variations. +pub(crate) trait DecodableResultExt +where + Self: Sized, +{ + /// Decodes a base64 encoded string from the result. + fn decode(self) -> Result; +} + +impl DecodableResultExt for serde_json::Value { + fn decode(self) -> Result { + Ok(self) + } +} + +pub(crate) fn decode_value(value: &str) -> anyhow::Result { + let decoded_bytes = general_purpose::STANDARD.decode(value)?; + Ok(std::str::from_utf8(&decoded_bytes)?.to_string()) +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result.rs new file mode 100644 index 0000000..05ca14d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result.rs @@ -0,0 +1,25 @@ +mod color_light; +mod default_plug_state; +mod default_state; +mod generic; +mod hub; +mod light; +mod plug; +mod plug_energy_monitoring; +mod power_status; +mod power_strip; +mod rgb_light_strip; +mod rgbic_light_strip; + +pub use color_light::*; +pub use default_plug_state::*; +pub use default_state::*; +pub use generic::*; +pub use hub::*; +pub use light::*; +pub use plug::*; +pub use plug_energy_monitoring::*; +pub use power_status::*; +pub use power_strip::*; +pub use rgb_light_strip::*; +pub use rgbic_light_strip::*; diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/color_light.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/color_light.rs new file mode 100644 index 0000000..59d99b4 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/color_light.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, DefaultStateType, TapoResponseExt, decode_value}; + +/// Device info of Tapo L530, L535 and L630. Superset of [`crate::responses::DeviceInfoGenericResult`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DeviceInfoColorLightResult { + // + // Inherited from DeviceInfoGenericResult + // + pub device_id: String, + pub r#type: String, + pub model: String, + pub hw_id: String, + pub hw_ver: String, + pub fw_id: String, + pub fw_ver: String, + pub oem_id: String, + pub mac: String, + pub ip: String, + pub ssid: String, + pub signal_level: u8, + pub rssi: i16, + pub specs: String, + pub lang: String, + pub device_on: bool, + /// The time in seconds this device has been ON since the last state change (On/Off). + /// On v2 hardware this is always None. + pub on_time: Option, + pub nickname: String, + pub avatar: String, + pub has_set_location_info: bool, + pub region: Option, + pub latitude: Option, + pub longitude: Option, + pub time_diff: Option, + // + // Unique to this device + // + pub brightness: u8, + pub color_temp: u16, + /// The default state of a device to be used when internet connectivity is lost after a power cut. + pub default_states: DefaultColorLightState, + pub dynamic_light_effect_enable: bool, + pub dynamic_light_effect_id: Option, + pub hue: Option, + pub overheated: bool, + pub saturation: Option, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceInfoColorLightResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for DeviceInfoColorLightResult {} + +impl DecodableResultExt for DeviceInfoColorLightResult { + fn decode(mut self) -> Result { + self.ssid = decode_value(&self.ssid)?; + self.nickname = decode_value(&self.nickname)?; + + Ok(self) + } +} + +/// Color Light Default State. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DefaultColorLightState { + pub r#type: DefaultStateType, + pub state: ColorLightState, +} + +/// Color Light State. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct ColorLightState { + pub brightness: u8, + pub hue: Option, + pub saturation: Option, + pub color_temp: u16, +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/default_plug_state.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/default_plug_state.rs new file mode 100644 index 0000000..6b2bb46 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/default_plug_state.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +/// Plug Default State. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "type")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub enum DefaultPlugState { + Custom { state: PlugState }, + LastStates {}, +} + +/// Plug State. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct PlugState { + pub on: bool, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl PlugState { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/default_state.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/default_state.rs new file mode 100644 index 0000000..2400c93 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/default_state.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +/// The type of the default state. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum DefaultStateType { + Custom, + LastStates, +} + +/// The type of the default power state. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum DefaultPowerType { + AlwaysOn, + LastStates, +} + +/// Default brightness state. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DefaultBrightnessState { + pub r#type: DefaultStateType, + pub value: u8, +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/generic.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/generic.rs new file mode 100644 index 0000000..bd9d140 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/generic.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, TapoResponseExt, decode_value}; + +/// Device info of a Generic Tapo device. +/// Please open an [issue on GitHub](https://github.com/mihai-dinculescu/tapo/issues) if you'd like to discuss +/// the possibility of adding support for a specific type of device that is currently unsupported. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DeviceInfoGenericResult { + pub device_id: String, + pub r#type: String, + pub model: String, + pub hw_id: String, + pub hw_ver: String, + pub fw_id: String, + pub fw_ver: String, + pub oem_id: String, + pub mac: String, + pub ip: String, + pub ssid: String, + pub signal_level: u8, + pub rssi: i16, + pub specs: String, + pub lang: String, + pub device_on: Option, + /// The time in seconds this device has been ON since the last state change (On/Off). + pub on_time: Option, + pub nickname: String, + pub avatar: String, + pub has_set_location_info: bool, + pub region: Option, + pub latitude: Option, + pub longitude: Option, + pub time_diff: Option, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceInfoGenericResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for DeviceInfoGenericResult {} + +impl DecodableResultExt for DeviceInfoGenericResult { + fn decode(mut self) -> Result { + self.ssid = decode_value(&self.ssid)?; + self.nickname = decode_value(&self.nickname)?; + + Ok(self) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/hub.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/hub.rs new file mode 100644 index 0000000..d44335f --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/hub.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, TapoResponseExt, decode_value}; + +/// Device info of Tapo H100. Superset of [`crate::responses::DeviceInfoGenericResult`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DeviceInfoHubResult { + // + // Inherited from DeviceInfoGenericResult + // + pub device_id: String, + pub r#type: String, + pub model: String, + pub hw_id: String, + pub hw_ver: String, + pub fw_id: String, + pub fw_ver: String, + pub oem_id: String, + pub mac: String, + pub ip: String, + pub ssid: String, + pub signal_level: u8, + pub rssi: i16, + pub specs: String, + pub lang: String, + pub nickname: String, + pub avatar: String, + pub has_set_location_info: bool, + pub region: Option, + pub latitude: Option, + pub longitude: Option, + pub time_diff: Option, + // + // Unique to this device + // + pub in_alarm_source: String, + pub in_alarm: bool, + pub overheated: bool, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceInfoHubResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for DeviceInfoHubResult {} + +impl DecodableResultExt for DeviceInfoHubResult { + fn decode(mut self) -> Result { + self.ssid = decode_value(&self.ssid)?; + self.nickname = decode_value(&self.nickname)?; + + Ok(self) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/light.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/light.rs new file mode 100644 index 0000000..7e1c878 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/light.rs @@ -0,0 +1,81 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{ + DecodableResultExt, DefaultBrightnessState, DefaultPowerType, TapoResponseExt, decode_value, +}; + +/// Device info of Tapo L510, L520 and L610. Superset of [`crate::responses::DeviceInfoGenericResult`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DeviceInfoLightResult { + // + // Inherited from DeviceInfoGenericResult + // + pub device_id: String, + pub r#type: String, + pub model: String, + pub hw_id: String, + pub hw_ver: String, + pub fw_id: String, + pub fw_ver: String, + pub oem_id: String, + pub mac: String, + pub ip: String, + pub ssid: String, + pub signal_level: u8, + pub rssi: i16, + pub specs: String, + pub lang: String, + pub device_on: bool, + /// The time in seconds this device has been ON since the last state change (On/Off). + /// On v2 hardware this is always None. + pub on_time: Option, + pub nickname: String, + pub avatar: String, + pub has_set_location_info: bool, + pub region: Option, + pub latitude: Option, + pub longitude: Option, + pub time_diff: Option, + // + // Unique to this device + // + pub brightness: u8, + /// The default state of a device to be used when internet connectivity is lost after a power cut. + pub default_states: DefaultLightState, + pub overheated: bool, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceInfoLightResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for DeviceInfoLightResult {} + +impl DecodableResultExt for DeviceInfoLightResult { + fn decode(mut self) -> Result { + self.ssid = decode_value(&self.ssid)?; + self.nickname = decode_value(&self.nickname)?; + + Ok(self) + } +} + +/// Light Default State. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DefaultLightState { + pub brightness: DefaultBrightnessState, + pub re_power_type: Option, +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/plug.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/plug.rs new file mode 100644 index 0000000..22f42c6 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/plug.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, DefaultPlugState, TapoResponseExt, decode_value}; + +/// Device info of Tapo P100 and P105. Superset of [`crate::responses::DeviceInfoGenericResult`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DeviceInfoPlugResult { + // + // Inherited from DeviceInfoGenericResult + // + pub device_id: String, + pub r#type: String, + pub model: String, + pub hw_id: String, + pub hw_ver: String, + pub fw_id: String, + pub fw_ver: String, + pub oem_id: String, + pub mac: String, + pub ip: String, + pub ssid: String, + pub signal_level: u8, + pub rssi: i16, + pub specs: String, + pub lang: String, + pub device_on: bool, + /// The time in seconds this device has been ON since the last state change (On/Off). + pub on_time: u64, + pub nickname: String, + pub avatar: String, + pub has_set_location_info: bool, + pub region: Option, + pub latitude: Option, + pub longitude: Option, + pub time_diff: Option, + // + // Unique to this device + // + pub default_states: DefaultPlugState, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceInfoPlugResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for DeviceInfoPlugResult {} + +impl DecodableResultExt for DeviceInfoPlugResult { + fn decode(mut self) -> Result { + self.nickname = decode_value(&self.nickname)?; + self.ssid = decode_value(&self.ssid)?; + + Ok(self) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/plug_energy_monitoring.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/plug_energy_monitoring.rs new file mode 100644 index 0000000..09a3a34 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/plug_energy_monitoring.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, TapoResponseExt, decode_value}; + +use super::{ + ChargingStatus, DefaultPlugState, OvercurrentStatus, OverheatStatus, PowerProtectionStatus, +}; + +/// Device info of Tapo P110, P110M and P115. Superset of [`crate::responses::DeviceInfoGenericResult`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs, deprecated)] +pub struct DeviceInfoPlugEnergyMonitoringResult { + // + // Inherited from DeviceInfoGenericResult + // + pub device_id: String, + pub r#type: String, + pub model: String, + pub hw_id: String, + pub hw_ver: String, + pub fw_id: String, + pub fw_ver: String, + pub oem_id: String, + pub mac: String, + pub ip: String, + pub ssid: String, + pub signal_level: u8, + pub rssi: i16, + pub specs: String, + pub lang: String, + pub device_on: bool, + /// The time in seconds this device has been ON since the last state change (On/Off). + pub on_time: u64, + pub nickname: String, + pub avatar: String, + pub has_set_location_info: bool, + pub region: Option, + pub latitude: Option, + pub longitude: Option, + pub time_diff: Option, + // + // Unique to this device + // + pub charging_status: ChargingStatus, + /// The default state of a device to be used when internet connectivity is lost after a power cut. + pub default_states: DefaultPlugState, + pub overcurrent_status: OvercurrentStatus, + pub overheat_status: Option, + pub power_protection_status: PowerProtectionStatus, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceInfoPlugEnergyMonitoringResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for DeviceInfoPlugEnergyMonitoringResult {} + +impl DecodableResultExt for DeviceInfoPlugEnergyMonitoringResult { + fn decode(mut self) -> Result { + self.ssid = decode_value(&self.ssid)?; + self.nickname = decode_value(&self.nickname)?; + + Ok(self) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/power_status.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/power_status.rs new file mode 100644 index 0000000..6380622 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/power_status.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum ChargingStatus { + Finished, + Normal, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum OvercurrentStatus { + Lifted, + Normal, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum OverheatStatus { + CoolDown, + Normal, + Overheated, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all, eq, eq_int))] +#[allow(missing_docs)] +pub enum PowerProtectionStatus { + Normal, + Overloaded, +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/power_strip.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/power_strip.rs new file mode 100644 index 0000000..9c95564 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/power_strip.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, TapoResponseExt, decode_value}; + +/// Device info of Tapo P300, P304M, P306 and P316M. Superset of [`crate::responses::DeviceInfoGenericResult`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DeviceInfoPowerStripResult { + // + // Inherited from DeviceInfoGenericResult + // + pub avatar: String, + pub device_id: String, + pub fw_id: String, + pub fw_ver: String, + pub has_set_location_info: bool, + pub hw_id: String, + pub hw_ver: String, + pub ip: String, + pub lang: String, + pub latitude: Option, + pub longitude: Option, + pub mac: String, + pub model: String, + pub oem_id: String, + pub region: Option, + pub rssi: i16, + pub signal_level: u8, + pub specs: String, + pub ssid: String, + pub time_diff: i64, + pub r#type: String, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceInfoPowerStripResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for DeviceInfoPowerStripResult {} + +impl DecodableResultExt for DeviceInfoPowerStripResult { + fn decode(mut self) -> Result { + self.ssid = decode_value(&self.ssid)?; + + Ok(self) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/rgb_light_strip.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/rgb_light_strip.rs new file mode 100644 index 0000000..c62c475 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/rgb_light_strip.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::responses::{DecodableResultExt, DefaultStateType, TapoResponseExt, decode_value}; + +/// Device info of Tapo L900. Superset of [`crate::responses::DeviceInfoGenericResult`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DeviceInfoRgbLightStripResult { + // + // Inherited from DeviceInfoGenericResult + // + pub device_id: String, + pub r#type: String, + pub model: String, + pub hw_id: String, + pub hw_ver: String, + pub fw_id: String, + pub fw_ver: String, + pub oem_id: String, + pub mac: String, + pub ip: String, + pub ssid: String, + pub signal_level: u8, + pub rssi: i16, + pub specs: String, + pub lang: String, + pub device_on: bool, + pub nickname: String, + pub avatar: String, + pub has_set_location_info: bool, + pub region: Option, + pub latitude: Option, + pub longitude: Option, + pub time_diff: Option, + // + // Unique to this device + // + pub brightness: u8, + pub color_temp_range: [u16; 2], + pub color_temp: u16, + /// The default state of a device to be used when internet connectivity is lost after a power cut. + pub default_states: DefaultRgbLightStripState, + pub hue: Option, + pub overheated: bool, + pub saturation: Option, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceInfoRgbLightStripResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for DeviceInfoRgbLightStripResult {} + +impl DecodableResultExt for DeviceInfoRgbLightStripResult { + fn decode(mut self) -> Result { + self.ssid = decode_value(&self.ssid)?; + self.nickname = decode_value(&self.nickname)?; + + Ok(self) + } +} + +/// RGB Light Strip Default State. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DefaultRgbLightStripState { + pub r#type: DefaultStateType, + pub state: RgbLightStripState, +} + +/// RGB Light Strip State. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct RgbLightStripState { + pub brightness: Option, + pub hue: Option, + pub saturation: Option, + pub color_temp: Option, +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/rgbic_light_strip.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/rgbic_light_strip.rs new file mode 100644 index 0000000..28036a3 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_info_result/rgbic_light_strip.rs @@ -0,0 +1,93 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Error; +use crate::requests::LightingEffect; +use crate::responses::{DecodableResultExt, DefaultStateType, TapoResponseExt, decode_value}; + +/// Device info of Tapo L920 and L930. Superset of [`crate::responses::DeviceInfoGenericResult`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DeviceInfoRgbicLightStripResult { + // + // Inherited from DeviceInfoGenericResult + // + pub device_id: String, + pub r#type: String, + pub model: String, + pub hw_id: String, + pub hw_ver: String, + pub fw_id: String, + pub fw_ver: String, + pub oem_id: String, + pub mac: String, + pub ip: String, + pub ssid: String, + pub signal_level: u8, + pub rssi: i16, + pub specs: String, + pub lang: String, + pub device_on: bool, + pub nickname: String, + pub avatar: String, + pub has_set_location_info: bool, + pub region: Option, + pub latitude: Option, + pub longitude: Option, + pub time_diff: Option, + // + // Unique to this device + // + pub brightness: u8, + pub color_temp_range: [u16; 2], + pub color_temp: u16, + /// The default state of a device to be used when internet connectivity is lost after a power cut. + pub default_states: DefaultRgbicLightStripState, + pub hue: Option, + pub overheated: bool, + pub saturation: Option, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceInfoRgbicLightStripResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +impl TapoResponseExt for DeviceInfoRgbicLightStripResult {} + +impl DecodableResultExt for DeviceInfoRgbicLightStripResult { + fn decode(mut self) -> Result { + self.ssid = decode_value(&self.ssid)?; + self.nickname = decode_value(&self.nickname)?; + + Ok(self) + } +} + +/// RGB IC Light Strip Default State. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct DefaultRgbicLightStripState { + pub r#type: DefaultStateType, + pub state: RgbicLightStripState, +} + +/// RGB IC Light Strip State. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +#[allow(missing_docs)] +pub struct RgbicLightStripState { + pub brightness: Option, + pub hue: Option, + pub saturation: Option, + pub color_temp: Option, + pub lighting_effect: Option, +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_usage_energy_monitoring_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_usage_energy_monitoring_result.rs new file mode 100644 index 0000000..0774832 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_usage_energy_monitoring_result.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +use super::{TapoResponseExt, UsageByPeriodResult}; + +/// Contains the time usage, the power consumption, and the energy savings of the device. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +pub struct DeviceUsageEnergyMonitoringResult { + /// Time usage in minutes. + pub time_usage: UsageByPeriodResult, + /// Power usage in Watt Hours (Wh). + pub power_usage: UsageByPeriodResult, + /// Saved power in Watt Hours (Wh). + pub saved_power: UsageByPeriodResult, +} +impl TapoResponseExt for DeviceUsageEnergyMonitoringResult {} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceUsageEnergyMonitoringResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/device_usage_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/device_usage_result.rs new file mode 100644 index 0000000..264517d --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/device_usage_result.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +use crate::responses::TapoResponseExt; +use crate::utils::ok_or_default; + +/// Contains the time usage. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +pub struct DeviceUsageResult { + /// Time usage in minutes. + pub time_usage: UsageByPeriodResult, +} +impl TapoResponseExt for DeviceUsageResult {} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl DeviceUsageResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +/// Usage by period result for today, the past 7 days, and the past 30 days. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +pub struct UsageByPeriodResult { + /// Today. + #[serde(deserialize_with = "ok_or_default")] + pub today: Option, + /// Past 7 days. + #[serde(deserialize_with = "ok_or_default")] + pub past7: Option, + /// Past 30 days. + #[serde(deserialize_with = "ok_or_default")] + pub past30: Option, +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/energy_data_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/energy_data_result.rs new file mode 100644 index 0000000..90bc102 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/energy_data_result.rs @@ -0,0 +1,106 @@ +use anyhow::Context as _; +use chrono::{DateTime, Local, NaiveDateTime, TimeZone as _, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::responses::TapoResponseExt; +use crate::utils::der_tapo_datetime_format; + +/// Energy data result for the requested [`crate::requests::PowerDataInterval`]. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +pub struct EnergyDataResult { + /// Local time of the device. + pub local_time: NaiveDateTime, + /// Start date and time of this result in UTC. + /// This value is provided in the `get_energy_data` request and is passed through. + /// Note that it may not align with the returned data if the method is used beyond its specified capabilities. + pub start_date_time: DateTime, + /// List of energy data entries. + pub entries: Vec, + /// Interval length in minutes. + pub interval_length: u64, +} + +impl TapoResponseExt for EnergyDataResult {} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl EnergyDataResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +/// Energy data result for a specific interval. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +pub struct EnergyDataIntervalResult { + /// Start date and time of this interval in UTC. + pub start_date_time: DateTime, + /// Energy in Watt Hours (Wh). + pub energy: u64, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl EnergyDataIntervalResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EnergyDataResultRaw { + #[serde(deserialize_with = "der_tapo_datetime_format")] + pub local_time: NaiveDateTime, + pub data: Vec, + pub start_timestamp: i64, + pub interval: u64, +} + +impl TapoResponseExt for EnergyDataResultRaw {} + +impl TryInto for EnergyDataResultRaw { + type Error = crate::error::Error; + + fn try_into(self) -> Result { + let mut entries = Vec::with_capacity(self.data.len()); + + let mut local_date_time = Local + .timestamp_opt(self.start_timestamp, 0) + .single() + .context("Failed to map start_timestamp to local time")?; + let start_date_time = local_date_time.to_utc(); + + for energy in self.data { + entries.push(EnergyDataIntervalResult { + start_date_time: local_date_time.to_utc(), + energy, + }); + local_date_time = match self.interval { + 60 => Ok(local_date_time + chrono::Duration::hours(1)), + 1440 => Ok(local_date_time + chrono::Duration::days(1)), + 43200 => Ok(local_date_time + chrono::Months::new(1)), + _ => Err(anyhow::anyhow!( + "Unsupported interval duration: {} minutes", + self.interval + )), + }?; + } + + Ok(EnergyDataResult { + local_time: self.local_time, + start_date_time, + entries, + interval_length: self.interval, + }) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/energy_usage_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/energy_usage_result.rs new file mode 100644 index 0000000..d6c90bb --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/energy_usage_result.rs @@ -0,0 +1,35 @@ +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; + +use crate::responses::TapoResponseExt; +use crate::utils::der_tapo_datetime_format; + +/// Contains local time, current power and the energy usage and runtime for today and for the current month. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +pub struct EnergyUsageResult { + /// Local time of the device. + #[serde(deserialize_with = "der_tapo_datetime_format")] + pub local_time: NaiveDateTime, + /// Today runtime in minutes. + pub today_runtime: u64, + /// Today energy usage in Watt Hours (Wh). + pub today_energy: u64, + /// Current month runtime in minutes. + pub month_runtime: u64, + /// Current month energy usage in Watt Hours (Wh). + pub month_energy: u64, +} +impl TapoResponseExt for EnergyUsageResult {} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl EnergyUsageResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/handshake_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/handshake_result.rs new file mode 100644 index 0000000..c771bee --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/handshake_result.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +use super::TapoResponseExt; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct HandshakeResult { + pub key: String, +} +impl TapoResponseExt for HandshakeResult {} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/power_data_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/power_data_result.rs new file mode 100644 index 0000000..c16481b --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/power_data_result.rs @@ -0,0 +1,145 @@ +use anyhow::Context as _; +use chrono::{DateTime, Local, TimeZone as _, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::responses::TapoResponseExt; + +/// Power data result for the requested [`crate::requests::PowerDataInterval`]. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +pub struct PowerDataResult { + /// Start date and time of this result in UTC. + pub start_date_time: DateTime, + /// End date and time of this result in UTC. + pub end_date_time: DateTime, + /// List of power data entries. + pub entries: Vec, + /// Interval length in minutes. + pub interval_length: u64, +} + +impl TapoResponseExt for PowerDataResult {} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl PowerDataResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +/// Power data result for a specific interval. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", pyo3::prelude::pyclass(get_all))] +pub struct PowerDataIntervalResult { + /// Start date and time of this interval in UTC. + pub start_date_time: DateTime, + /// Power in Watts (W). `None` if no data is available for this interval. + pub power: Option, +} + +#[cfg(feature = "python")] +#[pyo3::pymethods] +impl PowerDataIntervalResult { + /// Gets all the properties of this result as a dictionary. + pub fn to_dict(&self, py: pyo3::Python) -> pyo3::PyResult> { + let value = serde_json::to_value(self) + .map_err(|e| pyo3::exceptions::PyException::new_err(e.to_string()))?; + + crate::python::serde_object_to_py_dict(py, &value) + } +} + +#[derive(Debug, Deserialize)] +pub(crate) struct PowerDataResultRaw { + #[serde(deserialize_with = "deserialize_power_data")] + pub data: Vec>, + pub start_timestamp: i64, + pub end_timestamp: i64, + pub interval: u64, +} + +impl TapoResponseExt for PowerDataResultRaw {} + +impl TryInto for PowerDataResultRaw { + type Error = crate::error::Error; + + fn try_into(self) -> Result { + let mut entries = Vec::with_capacity(self.data.len()); + + let interval_duration = match self.interval { + 5 => Ok(chrono::Duration::minutes(5)), + 60 => Ok(chrono::Duration::hours(1)), + _ => Err(anyhow::anyhow!( + "Unsupported interval duration: {} minutes", + self.interval + )), + }?; + + let mut local_date_time = Local + .timestamp_opt(self.start_timestamp, 0) + .single() + .context("Failed to map start_timestamp to local time")?; + + let start_date_time = local_date_time.to_utc(); + let end_date_time = Local + .timestamp_opt(self.end_timestamp, 0) + .single() + .context("Failed to map end_timestamp to local time")? + .to_utc(); + + for power in self.data { + entries.push(PowerDataIntervalResult { + start_date_time: local_date_time.to_utc(), + power, + }); + local_date_time += interval_duration; + } + + Ok(PowerDataResult { + start_date_time, + end_date_time, + entries, + interval_length: self.interval, + }) + } +} + +fn deserialize_power_data<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + let raw = Vec::::deserialize(deserializer)?; + let mut out = Vec::with_capacity(raw.len()); + for v in raw { + match v { + serde_json::Value::Null => out.push(None), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + if i == -1 { + out.push(None); + } else if i >= 0 { + out.push(Some(i as u64)); + } else { + return Err(D::Error::custom(format!( + "Negative value {i} not allowed (except -1 sentinel)" + ))); + } + } else { + return Err(D::Error::custom("Number out of i64 range")); + } + } + other => { + return Err(D::Error::custom(format!( + "Unexpected value in power data array: {other}" + ))); + } + } + } + Ok(out) +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/schedule_rules_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/schedule_rules_result.rs new file mode 100644 index 0000000..c220c1e --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/schedule_rules_result.rs @@ -0,0 +1,94 @@ +//! Schedule and countdown rules response types. + +use serde::{Deserialize, Serialize}; + +use super::TapoResponseExt; + +/// A countdown timer rule +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CountdownRule { + /// Rule ID + pub id: String, + /// Whether the rule is enabled + pub enable: bool, + /// Delay in seconds until the action triggers + pub delay: u64, + /// Seconds remaining (if timer is active) + pub remain: u64, + /// Action when countdown completes: true = turn on, false = turn off + #[serde(rename = "desired_states")] + pub desired_states: Option, +} + +/// Desired state for countdown +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CountdownDesiredState { + /// Whether device should be on after countdown + pub on: Option, +} + +/// A scheduled rule +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ScheduleRule { + /// Rule ID + pub id: String, + /// Whether the rule is enabled + pub enable: bool, + /// Weekday mask (bits 0-6 for Sun-Sat) + #[serde(default)] + pub wday: Vec, + /// Start minute of day (0-1439) + #[serde(rename = "s_min", default)] + pub start_min: u16, + /// End minute of day (for duration schedules) + #[serde(rename = "e_min", default)] + pub end_min: u16, + /// Action: true = turn on, false = turn off + #[serde(rename = "desired_states")] + pub desired_states: Option, +} + +/// Desired state for schedule +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ScheduleDesiredState { + /// Whether device should be on + pub on: Option, +} + +/// Next scheduled event +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NextEventResult { + /// Schedule type + #[serde(rename = "schd_type")] + pub schedule_type: Option, + /// Timestamp of next event (seconds since epoch) + pub timestamp: Option, + /// Action for the event + pub action: Option, +} + +/// Result wrapper for countdown rules +#[derive(Debug, Clone, Deserialize)] +pub struct CountdownRulesResult { + /// List of countdown rules + #[serde(rename = "countdown_rules")] + pub rules: Vec, + /// Sum of rules (for pagination) + pub sum: Option, +} + +impl TapoResponseExt for CountdownRulesResult {} + +/// Result wrapper for schedule rules +#[derive(Debug, Clone, Deserialize)] +pub struct ScheduleRulesResult { + /// List of schedule rules + #[serde(rename = "schedule_rules")] + pub rules: Vec, + /// Sum of rules (for pagination) + pub sum: Option, +} + +impl TapoResponseExt for ScheduleRulesResult {} + +impl TapoResponseExt for NextEventResult {} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/supported_alarm_type_list_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/supported_alarm_type_list_result.rs new file mode 100644 index 0000000..784f5f1 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/supported_alarm_type_list_result.rs @@ -0,0 +1,13 @@ +use serde::Deserialize; + +use super::TapoResponseExt; + +/// Contains a list of supported alarm types (ringtones) of the device. +/// Useful for debugging only. +#[derive(Debug, Deserialize)] +pub struct SupportedAlarmTypeListResult { + /// Available alarm types supported by the play_alarm request + pub alarm_type_list: Vec, +} + +impl TapoResponseExt for SupportedAlarmTypeListResult {} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/tapo_response.rs b/agents/tapo/tapo-fork/tapo/src/responses/tapo_response.rs new file mode 100644 index 0000000..5cb90d7 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/tapo_response.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, TapoResponseError}; + +/// Implemented by all Tapo Responses. +pub(crate) trait TapoResponseExt {} + +impl TapoResponseExt for serde_json::Value {} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct TapoResponse { + pub error_code: i32, + pub result: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct TapoMultipleResponse { + pub result: TapoMultipleResult, +} + +impl TapoResponseExt for TapoMultipleResponse where T: TapoResponseExt {} + +#[derive(Debug, Deserialize)] +pub(crate) struct TapoMultipleResult { + pub responses: Vec>, +} + +pub(crate) fn validate_response( + response: &TapoResponse, +) -> Result<(), Error> { + match response.error_code { + 0 => Ok(()), + -1002 => Err(Error::Tapo(TapoResponseError::InvalidRequest)), + -1003 => Err(Error::Tapo(TapoResponseError::MalformedRequest)), + -1008 => Err(Error::Tapo(TapoResponseError::InvalidParameters)), + -1010 => Err(Error::Tapo(TapoResponseError::InvalidPublicKey)), + -1501 => Err(Error::Tapo(TapoResponseError::Unauthorized { + code: "INVALID_CREDENTIALS".to_string(), + description: + "Please verify that your email and password are correct—both are case-sensitive." + .to_string(), + })), + 9999 => Err(Error::Tapo(TapoResponseError::SessionTimeout)), + code => Err(Error::Tapo(TapoResponseError::Unknown(code))), + } +} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/tapo_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/tapo_result.rs new file mode 100644 index 0000000..34f45d8 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/tapo_result.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +use crate::responses::TapoResponseExt; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct TapoResult { + pub response: String, +} +impl TapoResponseExt for TapoResult {} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/token_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/token_result.rs new file mode 100644 index 0000000..d1ebfcd --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/token_result.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +use crate::responses::TapoResponseExt; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct TokenResult { + pub token: String, +} +impl TapoResponseExt for TokenResult {} diff --git a/agents/tapo/tapo-fork/tapo/src/responses/trigger_logs_result.rs b/agents/tapo/tapo-fork/tapo/src/responses/trigger_logs_result.rs new file mode 100644 index 0000000..b1df0e4 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/responses/trigger_logs_result.rs @@ -0,0 +1,16 @@ +use serde::Deserialize; + +use super::TapoResponseExt; + +/// Trigger logs result. +#[derive(Debug, Deserialize)] +pub struct TriggerLogsResult { + /// The `id` of the most recent log item that is returned. + pub start_id: u64, + /// The total number of log items that the hub holds for this device. + pub sum: u64, + /// Log items in reverse chronological order (newest first). + pub logs: Vec, +} + +impl TapoResponseExt for TriggerLogsResult {} diff --git a/agents/tapo/tapo-fork/tapo/src/utils.rs b/agents/tapo/tapo-fork/tapo/src/utils.rs new file mode 100644 index 0000000..a753856 --- /dev/null +++ b/agents/tapo/tapo-fork/tapo/src/utils.rs @@ -0,0 +1,25 @@ +use std::str::FromStr; + +use chrono::NaiveDateTime; +use serde::{Deserialize, Deserializer}; + +pub fn der_tapo_datetime_format<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let mut s = String::deserialize(deserializer)?; + if !s.contains('T') { + s = s.replace(' ', "T"); + } + let value = NaiveDateTime::from_str(&s).map_err(serde::de::Error::custom)?; + + Ok(value) +} + +pub fn ok_or_default<'de, T, D>(deserializer: D) -> Result +where + T: Deserialize<'de> + Default, + D: Deserializer<'de>, +{ + Ok(Deserialize::deserialize(deserializer).unwrap_or_default()) +}