Compare commits

...

5 commits

Author SHA1 Message Date
1a4694bad5 Add API struct 2024-02-02 19:57:43 +00:00
5ec9079456 Avoid unnecessary item paths 2024-02-02 19:57:03 +00:00
7d42a56199 Add error enum 2024-02-02 19:56:49 +00:00
1da05e29e1 Fix serialization/deserialization of status codes 2024-02-02 19:56:49 +00:00
375377f56a Use type-based data collections 2024-02-02 19:56:40 +00:00
4 changed files with 163 additions and 72 deletions

34
Cargo.lock generated
View file

@ -176,7 +176,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json",
"serde_repr",
"strum_macros", "strum_macros",
"thiserror",
"time", "time",
] ]
@ -732,6 +735,17 @@ dependencies = [
"serde", "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]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@ -827,6 +841,26 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "time" name = "time"
version = "0.3.32" version = "0.3.32"

View file

@ -10,3 +10,6 @@ serde = { version = "1.0", features = ["derive"]}
reqwest = { version = "0.11", features = ["blocking", "json"]} reqwest = { version = "0.11", features = ["blocking", "json"]}
strum_macros = { version = "0.26.1"} strum_macros = { version = "0.26.1"}
time = { version = "0.3.32", features = ["serde", "serde-well-known"]} 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,16 +1,96 @@
use serde::{Deserialize, Serialize}; use reqwest::{blocking::Client, Url};
use std::net::IpAddr; 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)] #[derive(Debug, Error)]
pub struct ValueError(String); pub enum Error {
#[error("unsupported API version {0}")]
impl std::fmt::Display for ValueError { UnsupportedApiVersion(u64),
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { #[error("invalid endpoint {0:?}")]
write!(f, "Invalid value given! Reason: {}", self.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),
} }
impl std::error::Error for ValueError {} 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<C, 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)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
@ -19,7 +99,7 @@ pub struct FroniousResponse<T> {
body: T, body: T,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)]
#[repr(u8)] #[repr(u8)]
pub enum StatusCode { pub enum StatusCode {
Okay = 0, Okay = 0,
@ -49,7 +129,7 @@ pub struct Status {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
pub struct CommonResponseHeader { pub struct CommonResponseHeader {
request_arguments: std::collections::HashMap<String, String>, request_arguments: HashMap<String, String>,
status: Status, status: Status,
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
timestamp: time::OffsetDateTime, timestamp: time::OffsetDateTime,
@ -72,43 +152,33 @@ pub struct CommonResponseBody<T> {
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
pub struct UnitAndValues<T> { pub struct UnitAndValues<T> {
unit: String, unit: String,
values: std::collections::HashMap<String, T>, values: HashMap<String, T>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
pub struct ApiVersion { struct ApiVersion {
#[serde(rename = "APIVersion")] #[serde(rename = "APIVersion")]
api_version: i8, api_version: u64,
#[serde(rename = "BaseURL")] #[serde(rename = "BaseURL")]
base_url: String, base_url: String,
compatibility_range: 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); pub struct DeviceId(u8);
impl TryFrom<u8> for DeviceId { #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
type Error = ValueError; #[error("invalid device ID, must be less than 100: {0}")]
pub struct InvalidDeviceId(u8);
fn try_from(device_id: u8) -> Result<Self, ValueError> { impl TryFrom<u8> for DeviceId {
type Error = InvalidDeviceId;
fn try_from(device_id: u8) -> Result<Self, InvalidDeviceId> {
if device_id <= 99 { if device_id <= 99 {
Ok(Self(device_id)) Ok(Self(device_id))
} else { } else {
Err(ValueError("device id not in range!".into())) Err(InvalidDeviceId(device_id))
} }
} }
} }
@ -119,48 +189,24 @@ impl From<DeviceId> for u8 {
} }
} }
#[derive(strum_macros::Display)] pub trait DataCollection: DeserializeOwned {
pub enum DataCollection { /// Returns the value of the `DataCollection` GET parameter for this collection.
CumulationInverterData, fn param_value() -> &'static str;
CommonInverterData,
ThreePInverterData,
MinMaxInverterData,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub struct CumulationInverterData<T> { pub struct CumulationInverterData {
pac: T, pac: UnitAndValue<u64>,
day_energy: T, day_energy: UnitAndValue<f64>,
year_energy: T, year_energy: UnitAndValue<f64>,
total_energy: T, total_energy: UnitAndValue<f64>,
device_status: Option<std::collections::HashMap<String, String>>, #[serde(rename = "DeviceStatus")]
device_status: Option<HashMap<String, serde_json::Value>>,
} }
pub fn get_inverter_realtime_data( impl DataCollection for CumulationInverterData {
ip: IpAddr, fn param_value() -> &'static str {
scope: Scope, "CumulationInverterData"
) -> Result<FroniousResponse, Box<dyn std::error::Error>> {
let mut params: Vec<(&str, String)> = vec![];
match scope {
Scope::System => {
params.push(("Scope", "System".to_owned()));
}
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 std::net::IpAddr;
use fronious::{CumulationInverterData, DeviceId, Fronius};
mod fronious; mod fronious;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let ip = IpAddr::V4(std::net::Ipv4Addr::new(10, 69, 0, 50)); 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)?); //println!("{:#?}", fronious::get_inverter_realtime_data(ip, fronious::Scope::System)?);
Ok(()) Ok(())
} }