geostar-symphony 0.1.3

An async wrapper around the Geostar Symphony cloud service API
Documentation
use std::sync::Arc;

use futures::sink::SinkExt;
use futures::stream::StreamExt;
use reqwest::cookie::CookieStore;
use tokio_tungstenite::tungstenite::protocol::Message;

mod error;
pub use error::{Error, InvalidGatewayKind};
#[allow(dead_code)] // Tons of fields we get back from the API and don't use
mod model;
use model::{DataRequest, DataResponse, GetLocationRequest, LocationResponse, LoginRequest, WsLoginRequest};
pub use model::{GatewayData, Location, TemperatureUnit, User};

#[cfg(feature = "clap")]
#[derive(Debug, clap::Parser)]
pub struct SymphonyConfig {
	#[clap(long, env = "SYMPHONY_EMAIL")]
	email: String,
	#[clap(long, env = "SYMPHONY_PASSWORD")]
	password: String,
	#[clap(long, env = "SYMPHONY_TEMPERATURE_UNIT", default_value = "F")]
	temperature_unit: TemperatureUnit,
}

#[cfg(feature = "clap")]
impl SymphonyConfig {
	pub async fn client(&self) -> Result<Client, Error> {
		Client::new(self.email.as_ref(), self.password.as_ref(), self.temperature_unit).await
	}
}

pub struct Client {
	http: reqwest::Client,
	cookies: Arc<reqwest::cookie::Jar>,
	user: User,
}

impl Client {
	pub async fn new(email: &str, password: &str, temperature_unit: TemperatureUnit) -> Result<Self, Error> {
		let url = "https://symphony.mygeostar.com/".parse().unwrap();
		let cookies = Arc::new(reqwest::cookie::Jar::default());
		cookies.add_cookie_str("legal-acknowledge=yes", &url);
		cookies.add_cookie_str(temperature_unit.as_cookie_str(), &url);
		let http = reqwest::Client::builder().cookie_provider(cookies.clone()).build()?;

		http.post("https://symphony.mygeostar.com/account/login").form(&LoginRequest::new(email, password)).send().await?;
		let user = match cfg!(test) {
			false => http.get("https://symphony.mygeostar.com/api.php/user").send().await?.json().await?,
			true => {
				let body = http.get("https://symphony.mygeostar.com/api.php/user").send().await?.text().await?;
				eprintln!("user response body:\n\t{body}\n");
				serde_json::from_str(&body).map_err(Error::JsonDeserialize)?
			}
		};

		Ok(Self { http, cookies, user })
	}

	pub fn user(&self) -> &User {
		&self.user
	}

	pub async fn get_locations(&self) -> Result<Vec<Location>, Error> {
		let response = self
			.http
			.post("https://symphony.mygeostar.com/api.php/v2/homeowner/15928/location")
			.header("Accept", "application/json")
			.form(&GetLocationRequest { awluserkey: self.user.awl_user_key })
			.send()
			.await?;

		let response: LocationResponse = match cfg!(test) {
			false => response.json().await?,
			true => {
				let body = response.text().await?;
				eprintln!("locations response body:\n\t{body}\n");
				serde_json::from_str(&body).map_err(Error::JsonDeserialize)?
			}
		};

		Ok(response.data)
	}

	pub async fn get_data(&self, awl_id: &str) -> Result<GatewayData, Error> {
		let (mut ws, _) = tokio_tungstenite::connect_async("wss://awlclientproxy.mygeostar.com/").await?;

		let cookies = self.cookies.cookies(&"https://symphony.mygeostar.com/".parse().unwrap()).ok_or(Error::MissingSessionId)?;
		let cookies = std::str::from_utf8(cookies.as_ref()).unwrap();
		let sessionid = cookies.split(';').find_map(|s| s.trim_start().strip_prefix("sessionid=")).ok_or(Error::MissingSessionId)?;
		let request = WsLoginRequest { cmd: "login", tid: 2, source: "consumer dashboard", sessionid };
		ws.send(Message::Text(serde_json::to_string(&request).map_err(Error::JsonSerialize)?.into())).await?;
		loop {
			match ws.next().await {
				Some(Ok(Message::Text(_))) => break, // TODO:  Handle errors in response
				Some(Ok(Message::Ping(_))) => ws.send(Message::Pong(vec![].into())).await?,
				Some(Ok(m)) => return Err(Error::InvalidResponse(m)),
				Some(Err(e)) => return Err(e.into()),
				None => return Err(Error::WebsocketClosed),
			};
		}

		let request = DataRequest {
			cmd: "read",
			tid: 1,
			awlid: awl_id,
			zone: 0,
			rlist: &[
				"compressorpower",
				"fanpower",
				"auxpower",
				"looppumppower",
				"totalunitpower",
				"AWLABCType",
				"ModeOfOperation",
				"ActualCompressorSpeed",
				"AirflowCurrentSpeed",
				"AuroraOutputEH1",
				"AuroraOutputEH2",
				"AuroraOutputCC",
				"AuroraOutputCC2",
				"TStatDehumidSetpoint",
				"TStatRelativeHumidity",
				"LeavingAirTemp",
				"TStatRoomTemp",
				"EnteringWaterTemp",
				"AOCEnteringWaterTemp",
				"auroraoutputrv",
				"AWLTStatType",
				"humidity_offset_settings",
				"iz2_humidity_offset_settings",
				"dehumid_humid_sp",
				"iz2_dehumid_humid_sp",
				"lockoutstatus",
				"lastfault",
				"lastlockout",
				"homeautomationalarm1",
				"homeautomationalarm2",
				"roomtemp",
				"activesettings",
				"TStatActiveSetpoint",
				"TStatMode",
				"TStatHeatingSetpoint",
				"TStatCoolingSetpoint",
			],
			source: "consumer dashboard",
		};
		ws.send(Message::Text(serde_json::to_string(&request).map_err(Error::JsonSerialize)?.into())).await?;
		let response: DataResponse = loop {
			match ws.next().await {
				Some(Ok(Message::Text(s))) => break serde_json::from_str(&s).map_err(Error::JsonDeserialize)?,
				Some(Ok(Message::Ping(_))) => ws.send(Message::Pong(vec![].into())).await?,
				Some(Ok(m)) => return Err(Error::InvalidResponse(m)),
				Some(Err(e)) => return Err(e.into()),
				None => return Err(Error::WebsocketClosed),
			};
		};
		// TODO:  Handle response.err
		Ok(response.into())
	}
}

#[cfg(test)]
mod tests {
	use std::env;

	use super::*;

	#[tokio::test]
	#[cfg_attr(not(integration_tests), ignore)]
	async fn test_login() {
		let email = env::var("SYMPHONY_EMAIL").unwrap();
		let password = env::var("SYMPHONY_PASSWORD").unwrap();
		let _client = Client::new(&email, &password, TemperatureUnit::Fahrenheit).await.unwrap();
	}

	#[tokio::test]
	#[cfg_attr(not(integration_tests), ignore)]
	async fn test_read() {
		let email = env::var("SYMPHONY_EMAIL").unwrap();
		let password = env::var("SYMPHONY_PASSWORD").unwrap();

		let client = Client::new(&email, &password, TemperatureUnit::Fahrenheit).await.unwrap();

		let locations = client.get_locations().await.unwrap();
		for location in locations {
			dbg!(&location);
			for gateway in location.gateways {
				dbg!(&gateway);

				let data = client.get_data(&gateway.gwid).await.unwrap();
				dbg!(&data);
			}
		}
	}
}