use reqwest::{blocking::Client, Url}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; use std::{borrow::Borrow, collections::HashMap, net::IpAddr}; use thiserror::Error; #[derive(Debug)] pub struct ValueError(String); impl std::fmt::Display for ValueError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "Invalid value given! Reason: {}", self.0) } } impl std::error::Error for ValueError {} #[derive(Debug, Error)] pub enum Error { #[error("unsupported API version {0}")] UnsupportedApiVersion(u64), #[error("invalid endpoint {0:?}")] InvalidEndpoint(String), #[error("request failed")] Request(#[from] reqwest::Error), #[error("decoding response body failed")] Decode(#[from] serde_json::Error), #[error("received error response {:?}: {}", .0.code, .0.reason)] Response(Status), } pub struct Fronius { client: Client, base_url: Url, } impl Fronius { pub fn connect(ip: IpAddr) -> Result { let client = Client::new(); let mut url = reqwest::Url::parse("http://placeholder.local/solar_api/GetAPIVersion.cgi") .expect("Initial base URL should be valid"); url.set_ip_host(ip) .expect("Base URL should be a valid base"); let api_version: ApiVersion = client.get(url.clone()).send()?.json()?; if api_version.api_version != 1 { return Err(Error::UnsupportedApiVersion(api_version.api_version)); } url.set_path(&api_version.base_url); Ok(Self { client, base_url: url, }) } fn make_request_inner(&self, url: Url) -> Result { let response: FroniousResponse = self.client.get(url).send()?.json()?; if response.head.status.code != StatusCode::Okay { return Err(Error::Response(response.head.status)); } Ok(response.body) } pub fn make_request(&self, endpoint: &str, params: I) -> Result where T: DeserializeOwned, I: IntoIterator, I::Item: Borrow<(K, V)>, K: AsRef, V: AsRef, { let mut url = self .base_url .join(endpoint) .map_err(|_e| Error::InvalidEndpoint(endpoint.to_string()))?; url.query_pairs_mut().extend_pairs(params); let body = self.make_request_inner(url)?; Ok(T::deserialize(body)?) } pub fn get_inverter_realtime_data_device( &self, device_id: DeviceId, ) -> Result { let device_id = u8::from(device_id).to_string(); let response: CommonResponseBody<_> = self.make_request( "GetInverterRealtimeData.cgi", [ ("Scope", "Device"), ("DeviceId", &device_id), ("DataCollection", C::param_value()), ], )?; Ok(response.data) } } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct FroniousResponse { head: CommonResponseHeader, body: T, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)] #[repr(u8)] pub enum StatusCode { Okay = 0, NotImplemented = 1, Uninitialized = 2, Initialized = 3, Running = 4, Timeout = 5, ArgumentError = 6, LNRequestError = 7, LNRequestTimeout = 8, LNParseError = 9, ConfigIOError = 10, NotSupported = 11, DeviceNotAvailable = 12, UnknownError = 255, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct Status { code: StatusCode, reason: String, user_message: String, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct CommonResponseHeader { request_arguments: HashMap, status: Status, #[serde(with = "time::serde::rfc3339")] timestamp: time::OffsetDateTime, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct UnitAndValue { unit: String, value: T, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct CommonResponseBody { data: T, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct UnitAndValues { unit: String, values: HashMap, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] struct ApiVersion { #[serde(rename = "APIVersion")] api_version: u64, #[serde(rename = "BaseURL")] base_url: String, compatibility_range: String, } pub struct DeviceId(u8); impl TryFrom for DeviceId { type Error = ValueError; fn try_from(device_id: u8) -> Result { if device_id <= 99 { Ok(Self(device_id)) } else { Err(ValueError("device id not in range!".into())) } } } impl From for u8 { fn from(device_id: DeviceId) -> u8 { device_id.0 } } pub trait DataCollection: DeserializeOwned { /// Returns the value of the `DataCollection` GET parameter for this collection. fn param_value() -> &'static str; } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub struct CumulationInverterData { pac: UnitAndValue, day_energy: UnitAndValue, year_energy: UnitAndValue, total_energy: UnitAndValue, #[serde(rename = "DeviceStatus")] device_status: Option>, } impl DataCollection for CumulationInverterData { fn param_value() -> &'static str { "CumulationInverterData" } }