Compare commits

...

5 commits

Author SHA1 Message Date
2f0822ff4a Add API struct 2024-02-02 15:27:46 +00:00
227c9fef43 Avoid unnecessary item paths 2024-02-02 15:27:17 +00:00
42d2f29bfc Add error enum 2024-02-02 15:26:44 +00:00
057b952d11 Fix serialization/deserialization of status codes 2024-02-02 15:25:26 +00:00
15d9d21c9b Use type-based data collections 2024-02-02 14:31:20 +00:00
4 changed files with 158 additions and 60 deletions

34
Cargo.lock generated
View file

@ -176,7 +176,10 @@ version = "0.1.0"
dependencies = [
"reqwest",
"serde",
"serde_json",
"serde_repr",
"strum_macros",
"thiserror",
"time",
]
@ -732,6 +735,17 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_repr"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@ -827,6 +841,26 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "thiserror"
version = "1.0.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "time"
version = "0.3.32"

View file

@ -10,3 +10,6 @@ serde = { version = "1.0", features = ["derive"]}
reqwest = { version = "0.11", features = ["blocking", "json"]}
strum_macros = { version = "0.26.1"}
time = { version = "0.3.32", features = ["serde", "serde-well-known"]}
serde_json = "1.0.113"
serde_repr = "0.1.18"
thiserror = "1.0.56"

View file

@ -1,5 +1,8 @@
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
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);
@ -12,6 +15,94 @@ impl std::fmt::Display for ValueError {
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> {
@ -19,7 +110,7 @@ pub struct FroniousResponse<T> {
body: T,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)]
#[repr(u8)]
pub enum StatusCode {
Okay = 0,
@ -49,7 +140,7 @@ pub struct Status {
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct CommonResponseHeader {
request_arguments: std::collections::HashMap<String, String>,
request_arguments: HashMap<String, String>,
status: Status,
#[serde(with = "time::serde::rfc3339")]
timestamp: time::OffsetDateTime,
@ -72,33 +163,19 @@ pub struct CommonResponseBody<T> {
#[serde(rename_all = "PascalCase")]
pub struct UnitAndValues<T> {
unit: String,
values: std::collections::HashMap<String, T>,
values: HashMap<String, T>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ApiVersion {
struct ApiVersion {
#[serde(rename = "APIVersion")]
api_version: i8,
api_version: u64,
#[serde(rename = "BaseURL")]
base_url: String,
compatibility_range: String,
}
pub fn get_api_version(ip: IpAddr) -> Result<ApiVersion, Box<dyn std::error::Error>> {
let mut url = reqwest::Url::parse("http://placeholder.local/solar_api/GetAPIVersion.cgi")?;
let _ = url.set_ip_host(ip);
Ok(reqwest::blocking::Client::new().get(url).send()?.json()?)
}
pub enum Scope {
System,
Device {
device_id: DeviceId,
data_collection: DataCollection,
},
}
pub struct DeviceId(u8);
impl TryFrom<u8> for DeviceId {
@ -119,48 +196,24 @@ impl From<DeviceId> for u8 {
}
}
#[derive(strum_macros::Display)]
pub enum DataCollection {
CumulationInverterData,
CommonInverterData,
ThreePInverterData,
MinMaxInverterData,
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 = "PascalCase")]
pub struct CumulationInverterData<T> {
pac: T,
day_energy: T,
year_energy: T,
total_energy: T,
device_status: Option<std::collections::HashMap<String, String>>,
#[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>>,
}
pub fn get_inverter_realtime_data(
ip: IpAddr,
scope: Scope,
) -> Result<FroniousResponse, Box<dyn std::error::Error>> {
let mut params: Vec<(&str, String)> = vec![];
match scope {
Scope::System => {
params.push(("Scope", "System".to_owned()));
impl DataCollection for CumulationInverterData {
fn param_value() -> &'static str {
"CumulationInverterData"
}
Scope::Device {
device_id,
data_collection,
} => {
params.push(("Scope", "Device".to_owned()));
params.push(("DeviceId", (u8::from(device_id)).to_string()));
params.push(("DataCollection", data_collection.to_string()));
}
}
let mut url = reqwest::Url::parse_with_params(
&format!("http://placeholder.local/solar_api/v1/GetInverterRealtimeData.cgi"),
&params,
)?;
let _ = url.set_ip_host(ip);
Ok(reqwest::blocking::Client::new().get(url).send()?.json()?)
}

View file

@ -1,9 +1,17 @@
use std::net::IpAddr;
use fronious::{CumulationInverterData, DeviceId, Fronius};
mod fronious;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ip = IpAddr::V4(std::net::Ipv4Addr::new(10, 69, 0, 50));
println!("{:#?}", fronious::get_api_version(ip)?);
let fronius = Fronius::connect(ip)?;
println!(
"{:?}",
fronius.get_inverter_realtime_data_device::<CumulationInverterData>(
DeviceId::try_from(0).unwrap(),
)?
);
//println!("{:#?}", fronious::get_inverter_realtime_data(ip, fronious::Scope::System)?);
Ok(())
}