enphase 0.4.0

An async wrapper around the Enphase APIs - both direct local access to Envoy devices, and the Enlighten cloud service
Documentation
use compact_str::CompactString;
use diqwest::WithDigestAuth;
use url::Url;

mod home;
pub use home::*;
mod info;
pub use info::*;
mod inventory;
pub use inventory::*;
mod inverters;
pub use inverters::*;
mod production;
pub use production::*;

#[cfg(feature = "clap")]
#[derive(Debug, clap::Parser)]
pub struct EnvoyConfig {
	#[clap(long, env = "ENVOY_URL")]
	envoy_base_url: CompactString,
	#[clap(long, env = "ENVOY_USERNAME")]
	envoy_username: CompactString,
	#[clap(long, env = "ENVOY_PASSWORD")]
	envoy_password: CompactString
}

#[cfg(feature = "clap")]
impl EnvoyConfig {
	#[inline]
	pub fn client(&self) -> Result<Client, url::ParseError> {
		Client::new(&self.envoy_base_url, &self.envoy_username, &self.envoy_password)
	}
}

pub struct Client {
	client: reqwest::Client,
	base_url: Url,
	username: CompactString,
	password: CompactString
}

#[derive(Debug, thiserror::Error)]
pub enum InfoError {
	#[error("HTTP error: {0}")]
	Http(#[from] reqwest::Error),
	#[error("XML error: {0}")]
	Xml(#[from] serde_xml_rs::Error)
}

impl Client {
	pub fn new(base_url: impl AsRef<str>, username: impl AsRef<str>, password: impl AsRef<str>) -> Result<Self, url::ParseError> {
		let mut base_url = base_url.as_ref().to_owned();
		if (!base_url.ends_with('/')) {
			base_url.push('/');
		}
		Ok(Self {
			base_url: Url::parse(&base_url)?,
			client: reqwest::Client::new(),
			username: username.as_ref().into(),
			password: password.as_ref().into()
		})
	}

	#[inline]
	pub fn base_url(&self) -> &Url {
		&self.base_url
	}

	pub async fn home(&self) -> Result<Home, reqwest::Error> {
		let url = self.base_url.join("home.json").unwrap();
		self.client.get(url).send().await?.error_for_status()?.json().await
	}

	pub async fn info(&self) -> Result<Info, InfoError> {
		let url = self.base_url.join("info.xml").unwrap();
		let response = self.client.get(url).send().await?.error_for_status()?.text().await?;
		Ok(serde_xml_rs::from_str(&response)?)
	}

	pub async fn inventory(&self) -> Result<Inventory, reqwest::Error> {
		let url = self.base_url.join("inventory.json").unwrap();
		self.client.get(url).send().await?.error_for_status()?.json().await
	}

	pub async fn inverters(&self) -> Result<Vec<Inverter>, diqwest::error::Error> {
		let url = self.base_url.join("api/v1/production/inverters").unwrap();
		let response = self.client.get(url).send_with_digest_auth(&self.username, &self.password).await?.error_for_status()?.json().await?;
		Ok(response)
	}

	pub async fn production(&self) -> Result<EnergyStats, reqwest::Error> {
		let url = self.base_url.join("production.json?details=1").unwrap();
		self.client.get(url).send().await?.error_for_status()?.json().await
	}
}

#[cfg(test)]
mod test {
	use super::*;

	fn client() -> Client {
		Client::new(std::env::var("ENVOY_URL").unwrap(), std::env::var("ENVOY_USERNAME").unwrap_or("".into()), std::env::var("ENVOY_PASSWORD").unwrap_or("".into())).unwrap()
	}

	#[tokio::test]
	#[cfg_attr(not(envoy_tests), ignore)]
	async fn test_home() {
		let client = client();
		client.home().await.unwrap();
	}

	#[tokio::test]
	#[cfg_attr(not(envoy_tests), ignore)]
	async fn test_info() {
		let client = client();
		client.info().await.unwrap();
	}

	#[tokio::test]
	#[cfg_attr(not(envoy_tests), ignore)]
	async fn test_inventory() {
		let client = client();
		client.inventory().await.unwrap();
	}

	#[tokio::test]
	#[cfg_attr(not(all(envoy_tests, envoy_auth_tests)), ignore)]
	async fn test_inverters() {
		let client = client();
		client.inverters().await.unwrap();
	}

	#[tokio::test]
	#[cfg_attr(not(envoy_tests), ignore)]
	async fn test_production() {
		let client = client();
		client.production().await.unwrap();
	}
}