220 lines
5.7 KiB
Rust
220 lines
5.7 KiB
Rust
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<Self, Error> {
|
|
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<serde_json::Value, Error> {
|
|
let response: FroniousResponse<serde_json::Value> = 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<T, I, K, V>(&self, endpoint: &str, params: I) -> Result<T, Error>
|
|
where
|
|
T: DeserializeOwned,
|
|
I: IntoIterator,
|
|
I::Item: Borrow<(K, V)>,
|
|
K: AsRef<str>,
|
|
V: AsRef<str>,
|
|
{
|
|
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<C: DataCollection>(
|
|
&self,
|
|
device_id: DeviceId,
|
|
) -> Result<CumulationInverterData, Error> {
|
|
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<T> {
|
|
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<String, String>,
|
|
status: Status,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
timestamp: time::OffsetDateTime,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
pub struct UnitAndValue<T> {
|
|
unit: String,
|
|
value: T,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
pub struct CommonResponseBody<T> {
|
|
data: T,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
pub struct UnitAndValues<T> {
|
|
unit: String,
|
|
values: HashMap<String, T>,
|
|
}
|
|
|
|
#[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<u8> for DeviceId {
|
|
type Error = ValueError;
|
|
|
|
fn try_from(device_id: u8) -> Result<Self, ValueError> {
|
|
if device_id <= 99 {
|
|
Ok(Self(device_id))
|
|
} else {
|
|
Err(ValueError("device id not in range!".into()))
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<DeviceId> 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<u64>,
|
|
day_energy: UnitAndValue<f64>,
|
|
year_energy: UnitAndValue<f64>,
|
|
total_energy: UnitAndValue<f64>,
|
|
#[serde(rename = "DeviceStatus")]
|
|
device_status: Option<HashMap<String, serde_json::Value>>,
|
|
}
|
|
|
|
impl DataCollection for CumulationInverterData {
|
|
fn param_value() -> &'static str {
|
|
"CumulationInverterData"
|
|
}
|
|
}
|