denki 0.1.2

CLI for controlling TP-Link smart home devices — denki (電気) means electricity in Japanese
Documentation
use std::sync::Arc;

use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use tokio::io::AsyncWrite;
use tokio::sync::RwLock;

use crate::tapo_vendor::api::ApiClient;
use crate::tapo_vendor::error::Error;
use crate::tapo_vendor::requests::{
    SmartCamGetGeneralDeviceListParams, SmartCamGetTimezoneParams,
    SmartCamSearchDateWithVideoParams, SmartCamSearchVideoWithUtcParams, TapoParams, TapoRequest,
};
use crate::tapo_vendor::responses::{
    DeviceInfoCameraHubResult, GeneralDeviceHubResult, GeneralDeviceListHubResultRaw,
    RecordingDateHubResult, RecordingDateListHubResultRaw, RecordingDownloadResult,
    RecordingHubResult, RecordingListHubResultRaw, TimezoneHubResultRaw, TimezoneResult,
};
use crate::tapo_vendor::utils::unix_timestamp_seconds;

/// Handler for camera hubs, such as the
/// [H200](https://www.tapo.com/en/search/?q=H200) and
/// [H500](https://www.tapo.com/en/search/?q=H500).
///
/// # Example
///
/// ```ignore
/// # use tapo::{ApiClient, HubDevice};
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// // Connect to the hub
/// let hub = ApiClient::new("tapo-username@example.com", "tapo-password")
///     .h200("192.168.1.100")
///     .await?;
/// // Get a handler for a child device
/// let device_id = "0000000000000000000000000000000000000000".to_string();
/// let device = hub.t31x(HubDevice::ByDeviceId(device_id)).await?;
/// // Get the device info of the child device
/// let device_info = device.get_device_info().await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct CameraHubHandler {
    client: Arc<RwLock<ApiClient>>,
    ip: String,
    port: u16,
    /// Identifies this handler to the hub, new for every handler. It is sent
    /// as `player_id` in clip searches (`searchVideoWithUTC`), and on the
    /// media stream as `playerId` in the stream URI and `player_id` in the
    /// download request.
    player_id: String,
}

impl CameraHubHandler {
    pub(crate) fn new(client: ApiClient, (ip, port): (String, u16)) -> Self {
        Self {
            client: Arc::new(RwLock::new(client)),
            ip,
            port,
            player_id: uuid::Uuid::new_v4().to_string(),
        }
    }
}

tapo_handler!(@methods CameraHubHandler(DeviceInfoCameraHubResult));
tapo_handler!(@handler_ext CameraHubHandler);

/// Hub handler methods.
impl CameraHubHandler {
    /// Returns *general device list* as [`GeneralDeviceHubResult`].
    /// These are the standalone Wi-Fi cameras paired to the hub.
    /// 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,
    #[cfg_attr(
        any(),
        doc = "try [`CameraHubHandler::get_general_device_list_json`] (requires the `debug` feature)."
    )]
    #[cfg_attr(
        not(any()),
        doc = "try `CameraHubHandler::get_general_device_list_json` (requires the `debug` feature)."
    )]
    pub async fn get_general_device_list(&self) -> Result<Vec<GeneralDeviceHubResult>, Error> {
        let request = TapoRequest::SmartCamGetGeneralDeviceList(TapoParams::new(
            SmartCamGetGeneralDeviceListParams::new(),
        ));

        self.client
            .read()
            .await
            .execute_smart_cam_multiple_request::<GeneralDeviceListHubResultRaw>(request)
            .await
            .map(|result| result.devices())
    }

    /// Returns *general device list* as [`serde_json::Value`].
    /// It contains all the properties returned from the Tapo API for the
    /// standalone Wi-Fi cameras paired to the hub.
    #[cfg(any())]
    pub async fn get_general_device_list_json(&self) -> Result<serde_json::Value, Error> {
        let request = TapoRequest::SmartCamGetGeneralDeviceList(TapoParams::new(
            SmartCamGetGeneralDeviceListParams::new(),
        ));

        self.client
            .read()
            .await
            .execute_smart_cam_multiple_request::<serde_json::Value>(request)
            .await
    }

    /// Returns the hub's *timezone* as [`TimezoneResult`].
    ///
    /// # Errors
    ///
    /// Returns an error if the hub reports a timezone name that is not in the
    /// IANA database.
    pub async fn get_timezone(&self) -> Result<TimezoneResult, Error> {
        let request =
            TapoRequest::SmartCamGetTimezone(TapoParams::new(SmartCamGetTimezoneParams::new()));

        self.client
            .read()
            .await
            .execute_smart_cam_multiple_request::<TimezoneHubResultRaw>(request)
            .await
            .map(Into::into)
    }

    /// Returns the days that have recordings stored on the hub for the given
    /// camera (`searchDateWithVideo`), as [`Vec<RecordingDateHubResult>`].
    /// Each day comes with the range of time it covers on the hub, ready to
    /// pass to [`CameraHubHandler::get_recordings`].
    ///
    /// The days are searched in the hub's timezone, so they line up with the
    /// Tapo app wherever this code runs. Each day that the range touches is
    /// searched whole: a range that starts at 00:30 covers all of that day,
    /// and a returned day can reach past the range at either end.
    ///
    /// Pass the [`TimezoneResult::timezone`] field from
    /// [`CameraHubHandler::get_timezone`] to read it once for several calls.
    /// With `None`, each call reads it, which takes one extra request.
    ///
    /// # Arguments
    ///
    /// * `child_device_id` - the `device_id` of a camera returned by [`CameraHubHandler::get_general_device_list`].
    /// * `start_time` - the start of the search range.
    /// * `end_time` - the end of the search range.
    /// * `timezone` - the hub's timezone, or `None` to read it from the hub.
    ///
    /// # Errors
    ///
    /// Returns an error if `end_time` is before `start_time`, or if the hub
    /// reports a timezone name that is not in the IANA database.
    pub async fn get_recording_dates(
        &self,
        child_device_id: impl Into<String>,
        start_time: DateTime<Utc>,
        end_time: DateTime<Utc>,
        timezone: Option<Tz>,
    ) -> Result<Vec<RecordingDateHubResult>, Error> {
        if end_time < start_time {
            return Err(Error::Validation {
                field: "end_time".to_string(),
                message: "Must not be before start_time".to_string(),
            });
        }

        let timezone = match timezone {
            Some(timezone) => timezone,
            None => self.get_timezone().await?.timezone,
        };

        let request = TapoRequest::SmartCamSearchDateWithVideo(TapoParams::new(
            SmartCamSearchDateWithVideoParams::new(
                start_time.with_timezone(&timezone).date_naive(),
                end_time.with_timezone(&timezone).date_naive(),
                child_device_id.into(),
            ),
        ));

        let dates = self
            .client
            .read()
            .await
            .execute_smart_cam_multiple_request::<RecordingDateListHubResultRaw>(request)
            .await?
            .dates(timezone)?;

        Ok(dates)
    }

    /// Returns the recordings stored on the hub for the given camera, within
    /// the given time range (`searchVideoWithUTC`), as
    /// [`Vec<RecordingHubResult>`]. Like the Tapo app, all pages are fetched,
    /// up to 12,300 recordings.
    ///
    /// # Arguments
    ///
    /// * `child_device_id` - the `device_id` of a camera returned by [`CameraHubHandler::get_general_device_list`].
    /// * `start_time` - the start of the search range.
    /// * `end_time` - the end of the search range.
    ///
    /// # Errors
    ///
    /// Returns an error if `end_time` is before `start_time`, or if either is
    /// before 1970.
    pub async fn get_recordings(
        &self,
        child_device_id: impl Into<String>,
        start_time: DateTime<Utc>,
        end_time: DateTime<Utc>,
    ) -> Result<Vec<RecordingHubResult>, Error> {
        // The Tapo app fetches pages of 100 (`start_index: 0`, `end_index: 99`)
        // and stops paging past a start index of 12288.
        const PAGE_SIZE: u64 = 100;
        const MAX_START_INDEX: u64 = 12288;

        if end_time < start_time {
            return Err(Error::Validation {
                field: "end_time".to_string(),
                message: "Must not be before start_time".to_string(),
            });
        }

        let start_time = unix_timestamp_seconds("start_time", start_time)?;
        let end_time = unix_timestamp_seconds("end_time", end_time)?;
        let child_device_id = child_device_id.into();

        let client = self.client.read().await;

        let mut results = Vec::new();
        let mut start_index = 0;

        while start_index <= MAX_START_INDEX {
            let request = TapoRequest::SmartCamSearchVideoWithUtc(TapoParams::new(
                SmartCamSearchVideoWithUtcParams::new(
                    start_time,
                    end_time,
                    start_index,
                    start_index + PAGE_SIZE - 1,
                    child_device_id.clone(),
                    self.player_id.clone(),
                ),
            ));

            let recordings = client
                .execute_smart_cam_multiple_request::<RecordingListHubResultRaw>(request)
                .await?
                .recordings();

            // The H200 sends no `to_be_continued` flag, so like the Tapo app
            // in that case, a full page means that another may follow.
            let page_is_full = recordings.len() as u64 >= PAGE_SIZE;
            results.extend(recordings);

            if !page_is_full {
                break;
            }
            start_index += PAGE_SIZE;
        }

        Ok(results)
    }

    /// Downloads a recording stored on the hub over the hub's media stream
    /// service (TCP port 8800), writing the media to `writer` as it arrives.
    ///
    /// The hub streams the recording as encrypted MPEG-TS; the parts are
    /// decrypted with keys derived from the session's key exchange, so
    /// writing to a file with a `.ts` extension produces a playable clip. The
    /// hub ends the clip itself, with twice the clip's length on top of the
    /// client's timeout as a backstop.
    ///
    /// The media is written exactly as the hub sent it, with nothing added or
    /// re-muxed, so a player may log a one-off `TS discontinuity` warning for
    /// the stream's PAT and PMT when it opens the file. It is harmless: both
    /// tables are repeated throughout the stream and the clip plays in full.
    ///
    /// # Arguments
    ///
    /// * `child_device_id` - the `device_id` of a camera returned by [`CameraHubHandler::get_general_device_list`].
    /// * `start_time` - the `start_time` of a recording returned by [`CameraHubHandler::get_recordings`].
    /// * `end_time` - the `end_time` of that recording.
    /// * `writer` - where the media is written, e.g. a `Vec<u8>` or a `tokio::fs::File`.
    ///
    /// # Errors
    ///
    /// Returns an error if `end_time` is not after `start_time`, if either is
    /// before 1970, if the hub rejects the request or sends no media, if it
    /// sends encrypted media that cannot be decrypted, if writing to `writer`
    /// fails, or if the hub closes the connection or the backstop runs out
    /// before the hub reports the end of the recording. In that last case `writer` holds only the part of
    /// the recording that arrived.
    ///
    /// # Example
    ///
    /// ```ignore
    /// # use tapo::ApiClient;
    /// # #[tokio::main]
    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// let hub = ApiClient::new("tapo-username@example.com", "tapo-password")
    ///     .h200("192.168.1.100")
    ///     .await?;
    ///
    /// let camera = hub
    ///     .get_general_device_list()
    ///     .await?
    ///     .into_iter()
    ///     .next()
    ///     .expect("no camera is paired to the hub");
    ///
    /// let end_time = chrono::Utc::now();
    /// let start_time = end_time - chrono::Duration::days(1);
    /// let recordings = hub
    ///     .get_recordings(camera.device_id.clone(), start_time, end_time)
    ///     .await?;
    ///
    /// if let Some(recording) = recordings.first() {
    ///     let mut media = Vec::new();
    ///     hub.download_recording(
    ///         camera.device_id,
    ///         recording.start_time,
    ///         recording.end_time,
    ///         &mut media,
    ///     )
    ///     .await?;
    ///     std::fs::write("recording.ts", media)?;
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub async fn download_recording<W: AsyncWrite + Unpin + Send>(
        &self,
        child_device_id: impl Into<String>,
        start_time: DateTime<Utc>,
        end_time: DateTime<Utc>,
        writer: &mut W,
    ) -> Result<RecordingDownloadResult, Error> {
        self.client
            .read()
            .await
            .download_recording(
                &self.ip,
                &self.player_id,
                child_device_id.into(),
                start_time,
                end_time,
                writer,
            )
            .await
    }
}

hub_child_handlers!(
    CameraHubHandler,
    ChildDeviceHubResult,
    child_device_list_note = "Cameras paired to the hub are not included; use \
        [`CameraHubHandler::get_general_device_list`] for those.",
);