tapo 0.9.0

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, S200D, S210) and sensors (KE100, T100, T110, T300, T310, T315).
Documentation
use std::fmt;

use base64::{Engine as _, engine::general_purpose};
use log::{debug, trace};
use reqwest::Client;
use reqwest::header::COOKIE;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};

use crate::api::protocol::TapoProtocol;
use crate::requests::{
    HandshakeParams, LoginDeviceParams, SecurePassthroughParams, TapoParams, TapoRequest,
};
use crate::responses::{TapoResponse, TapoResponseExt, TapoResult, TokenResult, validate_response};

use crate::{Error, TapoResponseError};

use super::aes_cipher::{AesCipher, AesKeyPair};

#[derive(Debug)]
pub(super) struct AesProtocol {
    client: Client,
    key_pair: AesKeyPair,
    session: Option<Session>,
}

#[derive(Debug)]
struct Session {
    url: String,
    cookie: String,
    cipher: AesCipher,
    token: Option<String>,
}

impl AesProtocol {
    pub fn new(client: Client) -> Result<Self, Error> {
        Ok(Self {
            client,
            key_pair: AesKeyPair::new(&mut rand::rng())?,
            session: None,
        })
    }

    pub async fn login(
        &mut self,
        url: String,
        username: String,
        password: String,
    ) -> Result<(), Error> {
        self.handshake(url).await?;
        self.login_request(username, password).await
    }

    pub async fn refresh_session(
        &mut self,
        username: String,
        password: String,
    ) -> Result<(), Error> {
        let url = self.session()?.url.clone();
        self.login(url, username, password).await
    }

    pub async fn execute_request<R>(&self, request: TapoRequest) -> Result<Option<R>, Error>
    where
        R: fmt::Debug + DeserializeOwned + TapoResponseExt,
    {
        let session = self.session()?;
        let url = match &session.token {
            Some(token) => format!("{}?token={token}", &session.url),
            None => 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::<TapoResponse<TapoResult>>()
            .await?;

        debug!("Device responded with: {response:?}");

        validate_response(response.error_code)?;

        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<R> = serde_json::from_str(&inner_response_decrypted)?;

        debug!("Device inner response: {inner_response:?}");

        validate_response(inner_response.error_code)?;

        let result = inner_response.result;

        Ok(result)
    }

    fn session(&self) -> Result<&Session, Error> {
        self.session
            .as_ref()
            .ok_or_else(|| anyhow::anyhow!("Session not initialized (login first)").into())
    }

    fn session_mut(&mut self) -> Result<&mut Session, Error> {
        self.session
            .as_mut()
            .ok_or_else(|| anyhow::anyhow!("Session not initialized (login first)").into())
    }

    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::<TapoResponse<HandshakeResult>>().await?;

        validate_response(response_json.error_code)?;

        let handshake_key = response_json
            .result
            .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))?
            .key;

        debug!("Handshake OK");

        let cipher = AesCipher::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 = AesCipher::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::<TokenResult>(request)
            .await?
            .ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))?;

        let session = self.session_mut()?;
        session.token.replace(result.token);

        Ok(())
    }
}

#[derive(Debug, Serialize, Deserialize)]
struct HandshakeResult {
    key: String,
}

impl TapoResponseExt for HandshakeResult {}