glowmarkt/
lib.rs

1//! Access to the Glowmarkt API for meter readings.
2//!
3//! Developed based on <https://bitbucket.org/ijosh/brightglowmarkt/src/master/>
4#![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
21/// The default API endpoint.
22pub const BASE_URL: &str = "https://api.glowmarkt.com/api/v0-1";
23/// The default application ID to use when communicating with the API.
24pub 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)]
39/// The time window for each reading.
40pub enum ReadingPeriod {
41    /// 30 minutes.
42    HalfHour,
43    /// 1 hour.
44    Hour,
45    /// 1 day.
46    Day,
47    /// 1 week.
48    Week,
49    /// 1 month.
50    Month,
51    /// 1 year.
52    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
66/// Attempts to align the given date to the start of a reading period.
67pub 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
115/// Splits a range of readings into a set of ranges that the API will accept.
116pub 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)]
182/// A meter reading
183pub struct Reading {
184    #[serde(with = "time::serde::rfc3339")]
185    /// The start time of the period.
186    pub start: OffsetDateTime,
187    /// The length of the period.
188    #[serde(skip)]
189    pub period: ReadingPeriod,
190    /// The total usage.
191    pub value: f32,
192}
193
194/// The API endpoint.
195///
196/// Normally a non-default endpoint would only be useful for testing purposes.
197#[derive(Debug, Clone)]
198pub struct GlowmarktEndpoint {
199    /// The URL of the API endpoint.
200    pub base_url: String,
201    /// The application ID to use when communicating with the endpoint.
202    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)]
258/// Access to the Glowmarkt API.
259pub struct GlowmarktApi {
260    /// The current JWT token.
261    pub token: String,
262    endpoint: GlowmarktEndpoint,
263    client: Client,
264}
265
266impl GlowmarktApi {
267    /// Create with a provided JWT token.
268    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    /// Authenticates with the default Glowmarkt API endpoint.
277    ///
278    /// Generates a valid JWT token if successful.
279    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    // fn post_request<S, T>(&self, path: S, data: &T) -> ApiRequest
318    // where
319    //     S: Display,
320    //     T: Serialize,
321    // {
322    //     let request = self
323    //         .client
324    //         .post(self.endpoint.url(path))
325    //         .header("Content-Type", "application/json")
326    //         .header("token", &self.token)
327    //         .json(data);
328
329    //     ApiRequest {
330    //         endpoint: &self.endpoint,
331    //         client: &self.client,
332    //         request,
333    //     }
334    // }
335}
336
337/// [User System](https://api.glowmarkt.com/api-docs/v0-1/usersys/usertypes/)
338impl GlowmarktApi {
339    /// Authenticate against a specific endpoint.
340    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    /// Validates the current token.
366    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
379/// [Device Management System](https://api.glowmarkt.com/api-docs/v0-1/dmssys/#/)
380impl GlowmarktApi {
381    /// Retrieves all of the known device types.
382    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    /// Retrieves all of the devices registered for an account.
390    pub async fn devices(&self) -> Result<HashMap<String, api::Device>, Error> {
391        self.get_request("device").request().await.map(build_map)
392    }
393
394    /// Retrieves a single device.
395    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
409/// [Virtual Entity System](https://api.glowmarkt.com/api-docs/v0-1/vesys/#/)
410impl GlowmarktApi {
411    /// Retrieves all of the virtual entities registered for an account.
412    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    /// Retrieves a single virtual entity by ID.
420    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
432/// [Resource System](https://api.glowmarkt.com/api-docs/v0-1/resourcesys/#/)
433impl GlowmarktApi {
434    /// Retrieves all of the known resource types.
435    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    /// Retrieves all resources.
443    pub async fn resources(&self) -> Result<HashMap<String, api::Resource>, Error> {
444        self.get_request("resource").request().await.map(build_map)
445    }
446
447    /// Retrieves a single resource by ID.
448    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    /// Retrieves the latest tariff that is being applied to a resource.
457    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    /// Retrieves the latest tariff that is being applied to a resource.
467    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    /// Retrieves the readings for a single resource.
477    ///
478    /// The API docs suggest that the start date should be set to the beginning
479    /// of the week (Monday) when the period is `Week` and the beginning of the
480    /// month when the period is `Month`. It is unclear what role the timezone
481    /// plays in this.
482    ///
483    /// The Glowmarkt API behaves strangely in the presence of non-UTC
484    /// timezones so `start` and `end` will first be converted to UTC and all
485    /// returned readings will be in UTC.
486    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}