sensor_community/
lib.rs

1//! API client for <https://sensor.community/>
2//!
3//! Based on <https://github.com/opendata-stuttgart/meta/wiki/EN-APIs>
4//! and <https://api-sensor-community.bessarabov.com/>
5//!
6//! Hosted at <https://codeberg.org/FedericoCeratto/rust-sensor-community-client>
7//!
8//! Released under GPL-3.0
9//!
10//! <img src="https://img.shields.io/badge/status-alpha-orange.svg">
11//! <img src="https://img.shields.io/badge/License-GPL-green.svg">
12//! <a href="https://api.reuse.software/info/codeberg.org/FedericoCeratto/rust-sensor-community-client">
13//! <img src="https://api.reuse.software/badge/codeberg.org/FedericoCeratto/rust-sensor-community-client">
14//! </a>
15//! <img src="https://custom-icon-badges.demolab.com/badge/hosted%20on-codeberg-4793CC.svg?logo=codeberg&logoColor=white">
16//!
17//! sensor.community recommends providing a contact point e.g. an email address when running queries
18
19use chrono::{DateTime, Utc};
20use reqwest::Client;
21use serde::Serialize;
22use serde::{self, Deserialize};
23use serde_json::Value;
24use thiserror::Error;
25
26const URL: &str = "https://data.sensor.community";
27
28#[derive(Error, Debug)]
29pub enum FetchError {
30    #[error("network request failed")]
31    NetworkError(#[from] reqwest::Error),
32    #[error("failed to parse JSON")]
33    JsonError(#[from] serde_json::Error),
34    #[error("unsupported value")]
35    UnsupportedError(),
36}
37
38/// Location of a measurement station
39#[derive(Serialize, Deserialize, Debug)]
40pub struct Location {
41    pub altitude: String,
42    /// 2-letter country code
43    pub country: String,
44    pub exact_location: u64,
45    pub id: u64,
46    /// indoor or outdoor as an integer
47    pub indoor: u64,
48    pub latitude: String,
49    pub longitude: String,
50}
51
52/// id, manufacturer and name of a sensor
53#[derive(Serialize, Deserialize, Debug)]
54pub struct SensorType {
55    pub id: u64,
56    pub manufacturer: String,
57    pub name: String,
58}
59
60/// Description of a sensor
61#[derive(Serialize, Deserialize, Debug)]
62pub struct Sensor {
63    pub id: u64,
64    // pin: value set during upload
65    pub pin: String,
66    /// sensor_type: 14 for SDS011, 17 for BME280 ...
67    pub sensor_type: SensorType,
68}
69
70/// A datapoint from a sensor
71#[derive(Serialize, Deserialize, Debug)]
72pub struct SensorDataValue {
73    pub id: Option<u64>,
74    pub value: Value,
75    // value_type: P1 for PM10, PM2 for PM2.5 ...
76    pub value_type: String,
77}
78
79/// An environmental sample from one measurement station fetched from the API.
80/// It can contain values from multiple onboard sensors.
81#[derive(Deserialize, Debug)]
82pub struct Sample {
83    /// MeasurementID: the ID obtained after uploading data
84    pub id: u64,
85    pub location: Location,
86    pub sensor: Sensor,
87    /// Datapoints from multiple sensors
88    pub sensordatavalues: Vec<SensorDataValue>,
89    /// UTC timestamp
90    #[serde(with = "simple_date_format")]
91    pub timestamp: DateTime<Utc>,
92}
93
94/// An environmental sample from one measurement station ready for upload.
95/// It can contain values from multiple onboard sensors.
96#[derive(Serialize, Debug)]
97pub struct SampleUpload {
98    /// software name and version
99    pub software_version: String,
100    /// Datapoints from multiple sensors
101    pub sensordatavalues: Vec<SensorDataValue>,
102}
103
104/// [de]serialize date to/from string
105mod simple_date_format {
106    use chrono::{DateTime, NaiveDateTime, Utc};
107    use serde::{self, Deserialize, Deserializer};
108    const FORMAT: &str = "%Y-%m-%d %H:%M:%S";
109    pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
110    where
111        D: Deserializer<'de>,
112    {
113        let s = String::deserialize(deserializer)?;
114        let dt = NaiveDateTime::parse_from_str(&s, FORMAT).map_err(serde::de::Error::custom)?;
115        Ok(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
116    }
117}
118
119/**
120    Fetch sensor data using a query.
121
122    **Warning**: this can return tenths of megabytes of data and/or consume significant resources on the server.
123
124    Supported values:
125     - `type={sensor type}`: comma-separated list of sensor types, e.g. SDS011,BME280
126     - `area={lat,lon,distance}`: all sensors within a max radius e.g. 52.5200,13.4050,10
127     - `box={lat1,lon1,lat2,lon2}`: all sensors in a 'box' with the given coordinates e.g. 52.1,13.0,53.5,13.5
128     - `country={country code}`: comma-separated list of country codes. Example BE,DE,NL
129*/
130pub async fn fetch_by_query(q: &str, contact: &str) -> Result<Vec<Sample>, FetchError> {
131    const URL: &str = "https://data.sensor.community/airrohr/v1/filter/";
132    let url = format!("{URL}{q}");
133    let client = Client::new();
134    let resp = client.get(url).header("User-Agent", contact).send().await?;
135    Ok(resp.json::<Vec<Sample>>().await?)
136}
137
138/** Fetch recent data in large batches.
139
140    **Warning**: this can return tenths of megabytes of data!
141
142    The `selector` argument can have values: "", "1h", "24h", "dust.min", "temp.min"
143    ```no_run
144       let x = fetch_batch("1h").await.unwrap();  // fetch data from the last hour
145    ```
146*/
147pub async fn fetch_batch(selector: &str, contact: &str) -> Result<Vec<Sample>, FetchError> {
148    let valid = ["", "1h", "24h", "dust.min", "temp.min"];
149    if !valid.contains(&selector) {
150        return Err(FetchError::UnsupportedError());
151    }
152    let url = {
153        if selector.is_empty() {
154            format!("{URL}/static/v2/data.json")
155        } else {
156            format!("{URL}/static/v2/data.{selector}.json")
157        }
158    };
159    let client = Client::new();
160    let resp = client.get(url).header("User-Agent", contact).send().await?;
161    Ok(resp.json::<Vec<Sample>>().await?)
162}
163
164/**
165    Fetch all measurements of the last 5 minutes for one sensor
166    The `API-ID` can be found by clicking on your sensor on the map. This is not the `chipID`.
167    ```no_run
168        let x = sensor_community::fetch_by_sensor(87420).await.unwrap();
169    ```
170*/
171pub async fn fetch_by_sensor(api_id: u64, contact: &str) -> Result<Vec<Sample>, FetchError> {
172    let url = format!("https://data.sensor.community/airrohr/v1/sensor/{api_id}/");
173    let client = Client::new();
174    let resp = client.get(url).header("User-Agent", contact).send().await?;
175    Ok(resp.json::<Vec<Sample>>().await?)
176}
177
178/**
179    Upload sensor readings from a measurement station.
180    * `station_uniq_id`: StationUniqID sometimes called ChipID or Sensor UID e.g. raspi-12345
181    * `pin`: Sensor type identification e.g. 1 for SDS011
182    * `sample`: [`SampleUpload`]
183    * `contact`: Contact point e.g. email address
184    ```no_run
185    let su = SampleUpload {
186        software_version: "rust-sensor-community-0.0.0".to_string(),
187        sensordatavalues: vec![SensorDataValue {
188            id: None,
189            value_type: "P2".to_string(),
190            value: serde_json::Value::String("3.22".to_string()),
191        }],
192    };
193    println!("sending data: {}", serde_json::to_string(&su).unwrap());
194    let r = sensor_community::upload("raspi-12345", 1, su, "foo@example.com").await;
195    println!("{:?}", r);
196    ```
197*/
198pub async fn upload(
199    station_uinq_id: &str,
200    pin: u32,
201    sample: SampleUpload,
202    contact: &str,
203) -> Result<(), FetchError> {
204    const URL: &str = "https://api.sensor.community/v1/push-sensor-data/";
205    let client = Client::new();
206    let resp = client
207        .post(URL)
208        .header("User-Agent", contact)
209        .header("X-PIN", pin)
210        .header("X-Sensor", station_uinq_id)
211        .json(&sample)
212        .send()
213        .await?;
214    Ok(resp.json().await?)
215}