solar_api/
lib.rs

1//! # Solar API
2//! Rust library for accessing the Solar Edge API. This library uses the API documentation found [here](https://knowledge-center.solaredge.com/en/search?search=api&sort_by=search_api_relevance)
3//!
4//! # API Key and Site ID
5//! To access the data of your installation, you need to get an API key. You can get this from the SolardEdge Monitoring Portal. Log in with your SolarEdge Account, go to the Admin section, Site Access tab and activate API access. Mark the checkbox and you will see the API Key and Site ID
6//!
7//! # Rate limited
8//! Please be aware that the API is rate limited, i.e. it will block requests after reaching a maximum of requests in an hour. It will be available again after that hour. Also note that the measurements seem to be limited to one per fifteen minutes. You can consider scheduling a read of data ±15 minutes after the timestamp of last read measurement. For example you can use a duration of 15m 10s:
9//!
10//! ```rust
11//! # use chrono::{Duration, Local};
12//! # let last_updated_datetime = Local::now();
13//! let next_update = last_updated_datetime + Duration::seconds(15 * 60 + 10);
14//! ```
15//!
16//! There is a convenience method to help with this:
17//!
18//! ```ignore
19//! let site_overview: Overview = overview(api_key, site_id);
20//! let (next_update, duration_from_now) = site_overview.estimated_next_update();
21//! ```
22//!
23//! Please note that sometimes the API is a bit later. The `duration_from_now` can be negative then and you have to wait a bit more like in the example below. Please note that `checked_add` is needed here to handle adding negative `duration_from_now`.
24//!
25//! ```ignore
26//! let site_overview: Overview = overview(api_key, site_id);
27//! let (next_update, duration_from_now) = site_overview.estimated_next_update();
28//!
29//! let next = Instant::now()
30//!     .checked_add(Duration::from_secs(duration_from_now as u64))
31//!     .unwrap_or(Instant::now() + Duration::from_secs(30));
32//!
33//! // wait next or set timeout at next_update before 
34//! // getting power or energy data
35// ```
36
37mod site;
38
39use chrono::NaiveDateTime;
40use log::{debug, trace};
41use reqwest::StatusCode;
42use std::collections::HashMap;
43use thiserror::Error;
44
45pub use site::{
46    DataPeriod, GeneratedEnergy, GeneratedEnergyValue, GeneratedPower, GeneratedPowerPerTimeUnit,
47    GeneratedPowerValue, Location, Overview, PrimaryModule, PublicSettings, Site, TimeData,
48    TimeUnit,
49};
50
51/// Possible errors that this lib can return. The underlying errors are included,
52/// either being [`request::Error``] or [`serde_json::Error`]
53#[derive(Error, Debug)]
54pub enum SolarApiError {
55    #[error("Could not retrieve data from SolarEdge Monitoring API")]
56    NetworkError(reqwest::Error),
57    #[error("API returned an Error")]
58    ApiError(reqwest::Error),
59    #[error("Not allowed to access API. Is the site id valid? Is your API token valid?")]
60    ForbiddenError(reqwest::Error),
61    #[error("Could not parse result from SolardEdge monitoring api")]
62    ParseError(#[from] serde_json::Error),
63}
64
65impl From<reqwest::Error> for SolarApiError {
66    fn from(error: reqwest::Error) -> Self {
67        if let Some(status) = error.status() {
68            if status.is_client_error() || status.is_server_error() {
69                if status == StatusCode::from_u16(403).unwrap() {
70                    return SolarApiError::ForbiddenError(error);
71                }
72                return SolarApiError::ApiError(error);
73            }
74        }
75        SolarApiError::NetworkError(error)
76    }
77}
78
79const BASE_URL: &str = "monitoringapi.solaredge.com";
80
81fn default_map(api_key: &str) -> HashMap<String, String> {
82    let mut map = HashMap::new();
83    map.insert("api_key".into(), api_key.into());
84    map
85}
86
87fn map_to_params(map: &HashMap<String, String>) -> String {
88    let mut params = map
89        .iter()
90        .fold(String::new(), |s, (k, v)| s + &format!("{}={}&", k, v));
91
92    // remove trailing &
93    params.pop();
94    params
95}
96
97fn to_url(path: &str, params: &HashMap<String, String>) -> String {
98    let params = map_to_params(params);
99    let url = format!("https://{}{}?{}", BASE_URL, path, params);
100    url
101}
102
103fn call_url(url: &str) -> Result<String, reqwest::Error> {
104    trace!("Calling {}", url);
105    let reply = reqwest::blocking::get(url)?.error_for_status()?;
106
107    trace!("reply: {:?}", reply);
108    let reply_text = reply.text()?;
109    trace!("reply text: {}", reply_text);
110    Ok(reply_text)
111}
112
113/// List all sites of customer. Each [`Site`] has an id that can be
114/// used to retrieve detailled information using for example [`energy`]
115pub fn list(api_key: &str) -> Result<Vec<site::Site>, SolarApiError> {
116    debug!("Calling list of sites");
117    let map = default_map(api_key);
118    let url = to_url("/sites/list", &map);
119    let reply_text = call_url(&url)?;
120
121    trace!("Parsing");
122    let reply: site::SitesReply = serde_json::from_str(&reply_text)?;
123
124    Ok((*reply.sites()).clone())
125}
126
127/// Displays the site details, such as name, location, status, etc.
128pub fn details(api_key: &str, site_id: u32) -> Result<site::Site, SolarApiError> {
129    debug!("Getting details of {site_id}");
130    let params = default_map(api_key);
131    let path = format!("/site/{site_id}/details");
132    let url = to_url(&path, &params);
133    let reply_text = call_url(&url)?;
134
135    trace!("Parsing json");
136    let site: site::SiteDetails = serde_json::from_str(&reply_text)?;
137
138    Ok(site.details)
139}
140
141/// Return the energy production start and end dates of the site
142pub fn data_period(api_key: &str, site_id: u32) -> Result<site::DataPeriod, SolarApiError> {
143    debug!("Getting data_period of {site_id}");
144    let params = default_map(api_key);
145    let path = format!("/site/{site_id}/dataPeriod");
146    let url = to_url(&path, &params);
147    let reply_text = call_url(&url)?;
148
149    trace!("Parsing json");
150    let period: site::DataPeriodReply = serde_json::from_str(&reply_text)?;
151
152    Ok(period.data_period)
153}
154
155/// Display the site overview data.
156pub fn overview(api_key: &str, site_id: u32) -> Result<site::Overview, SolarApiError> {
157    debug!("Getting overview of {}", site_id);
158    let params = default_map(api_key);
159    let path = format!("/site/{}/overview", site_id);
160    let url = to_url(&path, &params);
161    let reply_text = call_url(&url)?;
162
163    trace!("Parsing json");
164    let overview: site::OverviewReply = serde_json::from_str(&reply_text)?;
165
166    Ok(overview.overview)
167}
168
169/// Return the site energy measurements. Usage limitation: This API is limited
170/// to one year when using `time_unit=`[`TimeUnit::Day`] (i.e., daily resolution)
171/// and to one month when using `time_unit=`[`TimeUnit::QuarterOfAnHour`] or
172/// `time_unit=`[`TimeUnit::Hour`]`. This means that the period between
173/// `period.end_time` and `period.start_time` should not exceed one year or one
174/// month respectively. If the period is longer, the system will generate error
175pub fn energy(
176    api_key: &str,
177    site_id: u32,
178    period: DataPeriod,
179    time_unit: TimeUnit,
180) -> Result<site::GeneratedEnergy, SolarApiError> {
181    debug!(
182        "Getting energy for {}-{} with unit {}",
183        period.start_date,
184        period.end_date,
185        time_unit.to_param()
186    );
187
188    let mut params = default_map(api_key);
189    params.insert("startDate".into(), period.formatted_start_date());
190    params.insert("endDate".into(), period.formatted_end_date());
191    params.insert("timeUnit".into(), time_unit.to_param().into());
192    let path = format!("/site/{site_id}/energy");
193    let url = to_url(&path, &params);
194    let reply_text = call_url(&url)?;
195
196    trace!("Parsing json");
197    let energy: site::GeneratedEnergyReply = serde_json::from_str(&reply_text)?;
198
199    Ok(energy.energy)
200}
201
202/// Return the site power measurements in 15 minutes resolution. This API is 
203/// limited to one-month period. This means that the period between `end_datetime`
204/// and `start_datetime` should not exceed one month. If the period is longer, 
205/// the system will generate error .
206pub fn power(
207    api_key: &str,
208    site_id: u32,
209    start_datetime: NaiveDateTime,
210    end_datetime: NaiveDateTime,
211) -> Result<site::GeneratedPowerPerTimeUnit, SolarApiError> {
212    debug!("Getting power for {}-{}", start_datetime, end_datetime,);
213
214    let mut params = default_map(api_key);
215    params.insert(
216        "startTime".into(),
217        format!("{}", start_datetime.format("%Y-%m-%d %H:%M:%S")),
218    );
219    params.insert(
220        "endTime".into(),
221        format!("{}", end_datetime.format("%Y-%m-%d %H:%M:%S")),
222    );
223    let path = format!("/site/{site_id}/power");
224    let url = to_url(&path, &params);
225    let reply_text = call_url(&url)?;
226
227    trace!("Parsing json");
228    let power: site::GeneratedPowerReply = serde_json::from_str(&reply_text)?;
229
230    Ok(power.power)
231}
232
233#[test]
234fn test_map_to_params() {
235    let mut map = HashMap::new();
236    map.insert("key".to_string(), "value".to_string());
237    map.insert("key2".to_string(), "value2".to_string());
238
239    let params = map_to_params(&map);
240    // order of k/v-pairs not known
241    assert!(params == "key=value&key2=value2" || params == "key2=value2&key=value");
242}