use std::fmt;
use log::debug;
use reqwest::cookie::Cookie;
use reqwest::{Client, StatusCode, Url};
use serde::de::DeserializeOwned;
use crate::tapo_vendor::Error;
use crate::tapo_vendor::TapoResponseError;
use crate::tapo_vendor::requests::TapoRequest;
use crate::tapo_vendor::responses::TapoResponseExt;
use super::aes_ssl_protocol::{AesSslLogin, AesSslProtocol};
use super::klap_protocol::KlapProtocol;
use super::tpap_protocol::{TpapInfo, TpapProtocol};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DeviceFamily {
Smart,
SmartCam,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AuthProtocol {
AesSsl,
Klap,
TpapAnnounced(TpapInfo),
TpapThenAesSsl,
Unknown,
}
enum ActiveProtocol {
AesSsl(AesSslProtocol),
Klap(KlapProtocol),
Tpap(Box<TpapProtocol>),
}
pub(crate) struct TapoProtocol {
client: Client,
device_family: DeviceFamily,
active: Option<ActiveProtocol>,
}
impl Clone for TapoProtocol {
fn clone(&self) -> Self {
Self {
client: self.client.clone(),
device_family: self.device_family,
active: None,
}
}
}
impl TapoProtocol {
pub fn new(client: Client) -> Self {
Self {
client,
device_family: DeviceFamily::Smart,
active: None,
}
}
pub fn device_family(&self) -> DeviceFamily {
self.device_family
}
pub async fn login(
&mut self,
ip_address: &str,
http_port: Option<u16>,
username: &str,
password: &str,
device_family: DeviceFamily,
auth_protocol: AuthProtocol,
) -> Result<(String, u16), Error> {
self.device_family = device_family;
if self.active.is_none() {
let active = match auth_protocol {
AuthProtocol::AesSsl => {
debug!("Using AES SSL protocol...");
ActiveProtocol::AesSsl(AesSslProtocol::new(self.client.clone()))
}
AuthProtocol::TpapThenAesSsl => {
return self
.login_tpap_then_aes_ssl(ip_address, username, password)
.await;
}
AuthProtocol::Klap => {
debug!("Using KLAP protocol (from discovery hint)...");
ActiveProtocol::Klap(KlapProtocol::new(self.client.clone()))
}
AuthProtocol::TpapAnnounced(info) => {
debug!("Using TPAP protocol (from discovery hint)...");
ActiveProtocol::Tpap(Box::new(TpapProtocol::new(
self.client.clone(),
info,
device_family,
)))
}
AuthProtocol::Unknown => {
let address = host_with_port(ip_address, http_port, 80);
match TpapProtocol::discover(&self.client, &address).await? {
Some(info) => {
debug!("Using TPAP protocol (negotiated)...");
ActiveProtocol::Tpap(Box::new(TpapProtocol::new(
self.client.clone(),
info,
device_family,
)))
}
None => {
debug!("Using KLAP protocol (negotiated)...");
ActiveProtocol::Klap(KlapProtocol::new(self.client.clone()))
}
}
}
};
self.active = Some(active);
}
let url = match &self.active {
Some(ActiveProtocol::AesSsl(_)) => format!("https://{ip_address}"),
Some(ActiveProtocol::Klap(_)) => {
format!("http://{}/app", host_with_port(ip_address, http_port, 80))
}
Some(ActiveProtocol::Tpap(p)) => p.info().url(ip_address)?,
None => unreachable!(),
};
let address = url_address(&url)?;
match &mut self.active {
Some(ActiveProtocol::AesSsl(p)) => {
p.login(url, username, password).await?.into_result()?
}
Some(ActiveProtocol::Klap(p)) => p.login(url, username, password).await?,
Some(ActiveProtocol::Tpap(p)) => p.login(url, username, password).await?,
None => unreachable!(),
}
Ok(address)
}
async fn login_tpap_then_aes_ssl(
&mut self,
ip_address: &str,
username: &str,
password: &str,
) -> Result<(String, u16), Error> {
let info = TpapInfo::camera();
let url = info.url(ip_address)?;
let address = url_address(&url)?;
debug!("Using TPAP protocol (tried first)...");
let mut tpap = TpapProtocol::new(self.client.clone(), info, self.device_family);
let tpap_error = match tpap.login(url.clone(), username, password).await {
Ok(()) => {
self.active = Some(ActiveProtocol::Tpap(Box::new(tpap)));
return Ok(address);
}
Err(error) if !falls_back_to_aes_ssl(&error) => return Err(error),
Err(error) => error,
};
debug!("Using AES SSL protocol (TPAP login refused)...");
let mut aes_ssl = AesSslProtocol::new(self.client.clone());
match aes_ssl.login(url, username, password).await {
Ok(AesSslLogin::LoggedIn) => {
self.active = Some(ActiveProtocol::AesSsl(aes_ssl));
Ok(address)
}
Ok(AesSslLogin::Refused(_)) => Err(tpap_error),
Err(Error::Tapo(TapoResponseError::Unauthorized {
kind: "HASH_MISMATCH",
..
})) if is_tpap_credentials(&tpap_error) => Err(tpap_error),
Err(error) => Err(error),
}
}
pub async fn refresh_session(&mut self, username: &str, password: &str) -> Result<(), Error> {
match &mut self.active {
Some(ActiveProtocol::AesSsl(p)) => p.refresh_session(username, password).await,
Some(ActiveProtocol::Klap(p)) => p.refresh_session(username, password).await,
Some(ActiveProtocol::Tpap(p)) => p.refresh_session(username, password).await,
None => Err(anyhow::anyhow!(
"Cannot refresh session: protocol not yet initialized (login first)"
)
.into()),
}
}
pub async fn execute_request<R>(&self, request: TapoRequest) -> Result<Option<R>, Error>
where
R: fmt::Debug + DeserializeOwned + TapoResponseExt,
{
match &self.active {
Some(ActiveProtocol::AesSsl(p)) => p.execute_request(request).await,
Some(ActiveProtocol::Klap(p)) => p.execute_request(request).await,
Some(ActiveProtocol::Tpap(p)) => p.execute_request(request).await,
None => Err(anyhow::anyhow!(
"Cannot execute request: protocol not yet initialized (login first)"
)
.into()),
}
}
pub fn get_cookie<'a>(mut cookies: impl Iterator<Item = Cookie<'a>>) -> Result<String, Error> {
let cookie = cookies.find(|c| c.name() == "TP_SESSIONID");
match cookie {
Some(cookie) => Ok(format!("{}={}", cookie.name(), cookie.value())),
None => Err(Error::Tapo(TapoResponseError::ResponseError {
description: "TP_SESSIONID cookie not found in response".to_string(),
})),
}
}
}
fn host_with_port(ip_address: &str, port: Option<u16>, default_port: u16) -> String {
match port.filter(|port| *port != default_port) {
Some(port) => format!("{ip_address}:{port}"),
None => ip_address.to_string(),
}
}
fn url_address(url: &str) -> Result<(String, u16), Error> {
let parsed = Url::parse(url).map_err(|e| anyhow::anyhow!("Invalid URL {url}: {e}"))?;
let host = parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("URL {url} does not name a host"))?;
let port = parsed
.port_or_known_default()
.ok_or_else(|| anyhow::anyhow!("URL {url} does not name a port"))?;
Ok((host.to_string(), port))
}
fn falls_back_to_aes_ssl(error: &Error) -> bool {
match error {
Error::Http(error) => !(error.is_connect() || error.is_timeout()),
Error::Tapo(TapoResponseError::Unauthorized {
kind: "TPAP_AUTH_ATTEMPTS_LIMIT",
..
}) => false,
_ => true,
}
}
fn is_tpap_credentials(error: &Error) -> bool {
matches!(
error,
Error::Tapo(TapoResponseError::Unauthorized {
kind: "TPAP_CREDENTIALS",
..
})
)
}
pub(super) fn request_error(status: StatusCode) -> Error {
let error = match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
TapoResponseError::session_expired("SESSION_TIMEOUT")
}
_ => TapoResponseError::HttpError {
status_code: status.as_u16(),
description: "Request failed".to_string(),
},
};
Error::Tapo(error)
}
#[cfg(test)]
mod tests {
use crate::tapo_vendor::responses::validate_response;
use super::*;
#[test]
fn host_with_port_names_a_port_other_than_the_default() {
assert_eq!(host_with_port("192.168.1.100", None, 80), "192.168.1.100");
assert_eq!(
host_with_port("192.168.1.100", Some(80), 80),
"192.168.1.100"
);
assert_eq!(
host_with_port("192.168.1.100", Some(443), 443),
"192.168.1.100"
);
assert_eq!(
host_with_port("192.168.1.100", Some(8080), 80),
"192.168.1.100:8080"
);
}
#[test]
fn url_address_is_the_host_and_the_port_of_the_url_or_the_default_of_its_scheme() {
let address = |ip: &str, port| (ip.to_string(), port);
assert_eq!(
url_address("http://192.168.1.100/app").unwrap(),
address("192.168.1.100", 80)
);
assert_eq!(
url_address("https://192.168.1.100").unwrap(),
address("192.168.1.100", 443)
);
assert_eq!(
url_address("http://192.168.1.100:8080/app").unwrap(),
address("192.168.1.100", 8080)
);
}
#[tokio::test]
async fn unreachable_device_does_not_fall_back_to_aes_ssl() {
let error: Error = Client::new()
.post("https://127.0.0.1:0/")
.send()
.await
.unwrap_err()
.into();
assert!(!falls_back_to_aes_ssl(&error));
}
#[test]
fn locked_device_does_not_fall_back_to_aes_ssl() {
let error = validate_response(-2101).unwrap_err();
assert!(!falls_back_to_aes_ssl(&error));
}
#[test]
fn refused_tpap_login_falls_back_to_aes_ssl() {
for error_code in [-40209, -40210, -40401, -2203] {
let error = validate_response(error_code).unwrap_err();
assert!(falls_back_to_aes_ssl(&error), "{error_code}");
}
let error = Error::Tapo(TapoResponseError::tpap_credentials());
assert!(falls_back_to_aes_ssl(&error));
}
#[test]
fn request_error_of_an_unknown_session_is_an_expired_session() {
for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] {
assert!(matches!(
request_error(status),
Error::Tapo(TapoResponseError::Unauthorized {
kind: "SESSION_TIMEOUT",
..
})
));
}
}
#[test]
fn request_error_of_another_status_is_an_http_error() {
assert!(matches!(
request_error(StatusCode::INTERNAL_SERVER_ERROR),
Error::Tapo(TapoResponseError::HttpError {
status_code: 500,
..
})
));
}
}
#[cfg(test)]
mod policy_tests {
use super::*;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn strict_policy_fixture() {
if std::env::var_os("DENKI_TPAP_POLICY_FIXTURE").is_none() {
return;
}
assert_eq!(std::env::var("DENKI_REQUIRE_TPAP").unwrap(), "1");
for auth in [AuthProtocol::Klap, AuthProtocol::AesSsl] {
let mut protocol = TapoProtocol::new(Client::builder().no_proxy().build().unwrap());
let error = protocol
.login(
"192.0.2.1",
None,
"sentinel-user",
"sentinel-pass",
DeviceFamily::Smart,
auth,
)
.await
.unwrap_err();
assert!(
matches!(error, Error::Other(ref e) if crate::error::code(e) == "authentication_policy")
);
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 1024];
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
request.extend_from_slice(&buf[..n]);
if request.windows(b"discover".len()).any(|w| w == b"discover") {
break;
}
assert!(request.len() < 4096);
}
assert!(!String::from_utf8_lossy(&request).contains("sentinel"));
socket
.write_all(
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await
.unwrap();
drop(socket);
assert!(
tokio::time::timeout(Duration::from_millis(150), listener.accept())
.await
.is_err()
);
});
let mut protocol = TapoProtocol::new(Client::builder().no_proxy().build().unwrap());
let error = protocol
.login(
"127.0.0.1",
Some(address.port()),
"sentinel-user",
"sentinel-pass",
DeviceFamily::Smart,
AuthProtocol::Unknown,
)
.await
.unwrap_err();
assert!(
matches!(error, Error::Other(ref e) if crate::error::code(e) == "authentication_policy")
);
server.await.unwrap();
}
#[test]
fn strict_policy_refuses_explicit_and_negotiated_fallback_before_authentication() {
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"tapo_vendor::api::protocol::tapo_protocol::policy_tests::strict_policy_fixture",
"--nocapture",
])
.env("DENKI_TPAP_POLICY_FIXTURE", "1")
.env("DENKI_REQUIRE_TPAP", "1")
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"));
}
}