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}