1#![warn(missing_docs)]
5
6use std::{collections::HashMap, fmt::Display};
7
8use api::{TariffData, TariffListData};
9use error::maybe;
10use reqwest::{Client, RequestBuilder};
11use serde::{de::DeserializeOwned, Serialize};
12use time::format_description::well_known::Rfc3339;
13use time::{Duration, Month, OffsetDateTime, UtcOffset};
14
15pub mod api;
16pub mod error;
17
18pub use api::{Device, DeviceType, Resource, ResourceType, VirtualEntity};
19pub use error::{Error, ErrorKind};
20
21pub const BASE_URL: &str = "https://api.glowmarkt.com/api/v0-1";
23pub const APPLICATION_ID: &str = "b0f1b774-a586-4f72-9edd-27ead8aa7a8d";
25
26fn iso(dt: OffsetDateTime) -> String {
27 format!(
28 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
29 dt.year(),
30 dt.month() as u8,
31 dt.day(),
32 dt.hour(),
33 dt.minute(),
34 dt.second()
35 )
36}
37
38#[derive(Debug, Clone, Copy)]
39pub enum ReadingPeriod {
41 HalfHour,
43 Hour,
45 Day,
47 Week,
49 Month,
51 Year,
53}
54
55fn clear_seconds(date: OffsetDateTime) -> OffsetDateTime {
56 date.replace_second(0)
57 .unwrap()
58 .replace_millisecond(0)
59 .unwrap()
60 .replace_microsecond(0)
61 .unwrap()
62 .replace_nanosecond(0)
63 .unwrap()
64}
65
66pub fn align_to_period(date: OffsetDateTime, period: ReadingPeriod) -> OffsetDateTime {
68 match period {
69 ReadingPeriod::HalfHour => {
70 if date.minute() >= 30 {
71 clear_seconds(date).replace_minute(30).unwrap()
72 } else {
73 clear_seconds(date).replace_minute(0).unwrap()
74 }
75 }
76 ReadingPeriod::Hour => clear_seconds(date).replace_minute(0).unwrap(),
77 _ => panic!(
78 "Aligning to anything other than half-hour and hour periods is currently unsupported."
79 ),
80 }
81}
82
83fn max_days_for_period(period: ReadingPeriod) -> i64 {
84 match period {
85 ReadingPeriod::HalfHour => 10,
86 ReadingPeriod::Hour => 31,
87 ReadingPeriod::Day => 31,
88 ReadingPeriod::Week => 6 * 7,
89 ReadingPeriod::Month => 366,
90 ReadingPeriod::Year => 366,
91 }
92}
93
94fn increase_by_period(date: OffsetDateTime, period: ReadingPeriod) -> OffsetDateTime {
95 let duration = match period {
96 ReadingPeriod::HalfHour => Duration::minutes(30),
97 ReadingPeriod::Hour => Duration::hours(1),
98 ReadingPeriod::Day => Duration::days(1),
99 ReadingPeriod::Week => Duration::days(7),
100 ReadingPeriod::Month => {
101 let month = date.month();
102 return if month == Month::December {
103 date.replace_month(Month::January).unwrap()
104 } else {
105 date.replace_month(Month::try_from(month as u8 + 1).unwrap())
106 .unwrap()
107 };
108 }
109 ReadingPeriod::Year => return date.replace_year(date.year() + 1).unwrap(),
110 };
111
112 date + duration
113}
114
115pub fn split_periods(
117 start: OffsetDateTime,
118 end: OffsetDateTime,
119 period: ReadingPeriod,
120) -> Vec<(OffsetDateTime, OffsetDateTime)> {
121 let mut ranges = Vec::new();
122
123 let duration = Duration::days(max_days_for_period(period));
124 let mut current = start.to_offset(UtcOffset::UTC);
125 let final_end = end.to_offset(UtcOffset::UTC);
126 loop {
127 let next_end = current + duration;
128 if next_end >= final_end {
129 ranges.push((current, final_end));
130 break;
131 } else {
132 ranges.push((current, next_end));
133 }
134
135 current = increase_by_period(next_end, period);
136 }
137
138 ranges
139}
140
141trait Identified {
142 fn id(&self) -> &str;
143}
144
145fn build_map<I: Identified>(list: Vec<I>) -> HashMap<String, I> {
146 list.into_iter()
147 .map(|v| (v.id().to_owned(), v))
148 .collect::<HashMap<String, I>>()
149}
150
151impl Identified for api::VirtualEntity {
152 fn id(&self) -> &str {
153 &self.id
154 }
155}
156
157impl Identified for api::DeviceType {
158 fn id(&self) -> &str {
159 &self.id
160 }
161}
162
163impl Identified for api::Device {
164 fn id(&self) -> &str {
165 &self.id
166 }
167}
168
169impl Identified for api::ResourceType {
170 fn id(&self) -> &str {
171 &self.id
172 }
173}
174
175impl Identified for api::Resource {
176 fn id(&self) -> &str {
177 &self.id
178 }
179}
180
181#[derive(Serialize, Debug)]
182pub struct Reading {
184 #[serde(with = "time::serde::rfc3339")]
185 pub start: OffsetDateTime,
187 #[serde(skip)]
189 pub period: ReadingPeriod,
190 pub value: f32,
192}
193
194#[derive(Debug, Clone)]
198pub struct GlowmarktEndpoint {
199 pub base_url: String,
201 pub app_id: String,
203}
204
205impl Default for GlowmarktEndpoint {
206 fn default() -> Self {
207 Self {
208 base_url: BASE_URL.to_string(),
209 app_id: APPLICATION_ID.to_string(),
210 }
211 }
212}
213
214impl GlowmarktEndpoint {
215 fn url<S: Display>(&self, path: S) -> String {
216 format!("{}/{}", self.base_url, path)
217 }
218
219 async fn api_call<T>(&self, client: &Client, request: RequestBuilder) -> Result<T, Error>
220 where
221 T: DeserializeOwned,
222 {
223 let request = request
224 .header("applicationId", &self.app_id)
225 .header("Content-Type", "application/json")
226 .build()?;
227
228 log::debug!("Sending {} request to {}", request.method(), request.url());
229 let response = client
230 .execute(request)
231 .await?
232 .error_for_status()
233 .map_err(|e| {
234 log::warn!("Received API error: {}", e);
235 e
236 })?;
237
238 let result = response.text().await?;
239 log::trace!("Received: {}", result);
240
241 Ok(serde_json::from_str::<T>(&result)?)
242 }
243}
244
245struct ApiRequest<'a> {
246 endpoint: &'a GlowmarktEndpoint,
247 client: &'a Client,
248 request: RequestBuilder,
249}
250
251impl ApiRequest<'_> {
252 async fn request<T: DeserializeOwned>(self) -> Result<T, Error> {
253 self.endpoint.api_call(self.client, self.request).await
254 }
255}
256
257#[derive(Debug, Clone)]
258pub struct GlowmarktApi {
260 pub token: String,
262 endpoint: GlowmarktEndpoint,
263 client: Client,
264}
265
266impl GlowmarktApi {
267 pub fn new(token: &str) -> Self {
269 Self {
270 token: token.to_owned(),
271 endpoint: Default::default(),
272 client: Client::new(),
273 }
274 }
275
276 pub async fn authenticate(username: &str, password: &str) -> Result<GlowmarktApi, Error> {
280 Self::auth(Default::default(), username, password).await
281 }
282
283 fn get_request<S>(&self, path: S) -> ApiRequest
284 where
285 S: Display,
286 {
287 let request = self
288 .client
289 .get(self.endpoint.url(path))
290 .header("token", &self.token);
291
292 ApiRequest {
293 endpoint: &self.endpoint,
294 client: &self.client,
295 request,
296 }
297 }
298
299 fn query_request<S, T>(&self, path: S, query: &T) -> ApiRequest
300 where
301 S: Display,
302 T: Serialize + ?Sized,
303 {
304 let request = self
305 .client
306 .get(self.endpoint.url(path))
307 .header("token", &self.token)
308 .query(query);
309
310 ApiRequest {
311 endpoint: &self.endpoint,
312 client: &self.client,
313 request,
314 }
315 }
316
317 }
336
337impl GlowmarktApi {
339 pub async fn auth(
341 endpoint: GlowmarktEndpoint,
342 username: &str,
343 password: &str,
344 ) -> Result<GlowmarktApi, Error> {
345 let client = Client::new();
346 let request = client.post(endpoint.url("auth")).json(&api::AuthRequest {
347 username: username.to_owned(),
348 password: password.to_owned(),
349 });
350
351 let response = endpoint
352 .api_call::<api::AuthResponse>(&client, request)
353 .await?
354 .validate()?;
355
356 log::debug!("Authenticated with API until {}", iso(response.expiry));
357
358 Ok(Self {
359 token: response.token,
360 endpoint,
361 client,
362 })
363 }
364
365 pub async fn validate(&self) -> Result<bool, Error> {
367 let response = self
368 .get_request("auth")
369 .request::<api::ValidateResponse>()
370 .await
371 .and_then(|r| r.validate())?;
372
373 log::debug!("Authenticated with API until {}", iso(response.expiry));
374
375 Ok(true)
376 }
377}
378
379impl GlowmarktApi {
381 pub async fn device_types(&self) -> Result<HashMap<String, api::DeviceType>, Error> {
383 self.get_request("devicetype")
384 .request()
385 .await
386 .map(build_map)
387 }
388
389 pub async fn devices(&self) -> Result<HashMap<String, api::Device>, Error> {
391 self.get_request("device").request().await.map(build_map)
392 }
393
394 pub async fn device(&self, id: &str) -> Result<Option<api::Device>, Error> {
396 match self.get_request(format!("device/{}", id)).request().await {
397 Ok(device) => Ok(Some(device)),
398 Err(error) => {
399 if error.kind == ErrorKind::NotFound {
400 Ok(None)
401 } else {
402 Err(error)
403 }
404 }
405 }
406 }
407}
408
409impl GlowmarktApi {
411 pub async fn virtual_entities(&self) -> Result<HashMap<String, api::VirtualEntity>, Error> {
413 self.get_request("virtualentity")
414 .request()
415 .await
416 .map(build_map)
417 }
418
419 pub async fn virtual_entity(
421 &self,
422 entity_id: &str,
423 ) -> Result<Option<api::VirtualEntity>, Error> {
424 maybe(
425 self.get_request(format!("virtualentity/{}", entity_id))
426 .request()
427 .await,
428 )
429 }
430}
431
432impl GlowmarktApi {
434 pub async fn resource_types(&self) -> Result<HashMap<String, api::ResourceType>, Error> {
436 self.get_request("resourcetype")
437 .request()
438 .await
439 .map(build_map)
440 }
441
442 pub async fn resources(&self) -> Result<HashMap<String, api::Resource>, Error> {
444 self.get_request("resource").request().await.map(build_map)
445 }
446
447 pub async fn resource(&self, resource_id: &str) -> Result<Option<api::Resource>, Error> {
449 maybe(
450 self.get_request(format!("resource/{}", resource_id))
451 .request()
452 .await,
453 )
454 }
455
456 pub async fn latest_tariff(&self, resource_id: &str) -> Result<Vec<TariffData>, Error> {
458 let response: api::LatestTariffResponse = self
459 .get_request(format!("resource/{}/tariff", resource_id))
460 .request()
461 .await?;
462
463 Ok(response.data)
464 }
465
466 pub async fn tariff_list(&self, resource_id: &str) -> Result<Vec<TariffListData>, Error> {
468 let response: api::TariffListResponse = self
469 .get_request(format!("resource/{}/tariff-list", resource_id))
470 .request()
471 .await?;
472
473 Ok(response.data)
474 }
475
476 pub async fn readings(
487 &self,
488 resource_id: &str,
489 start: &OffsetDateTime,
490 end: &OffsetDateTime,
491 period: ReadingPeriod,
492 ) -> Result<Vec<Reading>, Error> {
493 log::trace!(
494 "Requesting readings for {} in range {} to {}, period {:?}",
495 resource_id,
496 start.format(&Rfc3339).unwrap(),
497 end.format(&Rfc3339).unwrap(),
498 period
499 );
500
501 let period_arg = match period {
502 ReadingPeriod::HalfHour => "PT30M".to_string(),
503 ReadingPeriod::Hour => "PT1H".to_string(),
504 ReadingPeriod::Day => "P1D".to_string(),
505 ReadingPeriod::Week => "P1W".to_string(),
506 ReadingPeriod::Month => "P1M".to_string(),
507 ReadingPeriod::Year => "P1Y".to_string(),
508 };
509
510 let readings = self
511 .query_request(
512 format!("resource/{}/readings", resource_id),
513 &[
514 ("from", iso(start.to_offset(UtcOffset::UTC))),
515 ("to", iso(end.to_offset(UtcOffset::UTC))),
516 ("period", period_arg),
517 ("offset", 0.to_string()),
518 ("function", "sum".to_string()),
519 ],
520 )
521 .request::<api::ReadingsResponse>()
522 .await?;
523
524 Ok(readings
525 .data
526 .into_iter()
527 .map(|(timestamp, value)| Reading {
528 start: OffsetDateTime::from_unix_timestamp(timestamp).unwrap(),
529 period,
530 value,
531 })
532 .collect())
533 }
534}