sensor-community 0.1.0

Client for the https://sensor.community/ API
Documentation
//! API client for <https://sensor.community/>
//!
//! Based on <https://github.com/opendata-stuttgart/meta/wiki/EN-APIs>
//! and <https://api-sensor-community.bessarabov.com/>
//!
//! Hosted at <https://codeberg.org/FedericoCeratto/rust-sensor-community-client>
//!
//! Released under GPL-3.0
//!
//! <img src="https://img.shields.io/badge/status-alpha-orange.svg">
//! <img src="https://img.shields.io/badge/License-GPL-green.svg">
//! <a href="https://api.reuse.software/info/codeberg.org/FedericoCeratto/rust-sensor-community-client">
//! <img src="https://api.reuse.software/badge/codeberg.org/FedericoCeratto/rust-sensor-community-client">
//! </a>
//! <img src="https://custom-icon-badges.demolab.com/badge/hosted%20on-codeberg-4793CC.svg?logo=codeberg&logoColor=white">
//!
//! sensor.community recommends providing a contact point e.g. an email address when running queries

use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::Serialize;
use serde::{self, Deserialize};
use serde_json::Value;
use thiserror::Error;

const URL: &str = "https://data.sensor.community";

#[derive(Error, Debug)]
pub enum FetchError {
    #[error("network request failed")]
    NetworkError(#[from] reqwest::Error),
    #[error("failed to parse JSON")]
    JsonError(#[from] serde_json::Error),
    #[error("unsupported value")]
    UnsupportedError(),
}

/// Location of a measurement station
#[derive(Serialize, Deserialize, Debug)]
pub struct Location {
    pub altitude: String,
    /// 2-letter country code
    pub country: String,
    pub exact_location: u64,
    pub id: u64,
    /// indoor or outdoor as an integer
    pub indoor: u64,
    pub latitude: String,
    pub longitude: String,
}

/// id, manufacturer and name of a sensor
#[derive(Serialize, Deserialize, Debug)]
pub struct SensorType {
    pub id: u64,
    pub manufacturer: String,
    pub name: String,
}

/// Description of a sensor
#[derive(Serialize, Deserialize, Debug)]
pub struct Sensor {
    pub id: u64,
    // pin: value set during upload
    pub pin: String,
    /// sensor_type: 14 for SDS011, 17 for BME280 ...
    pub sensor_type: SensorType,
}

/// A datapoint from a sensor
#[derive(Serialize, Deserialize, Debug)]
pub struct SensorDataValue {
    pub id: Option<u64>,
    pub value: Value,
    // value_type: P1 for PM10, PM2 for PM2.5 ...
    pub value_type: String,
}

/// An environmental sample from one measurement station fetched from the API.
/// It can contain values from multiple onboard sensors.
#[derive(Deserialize, Debug)]
pub struct Sample {
    /// MeasurementID: the ID obtained after uploading data
    pub id: u64,
    pub location: Location,
    pub sensor: Sensor,
    /// Datapoints from multiple sensors
    pub sensordatavalues: Vec<SensorDataValue>,
    /// UTC timestamp
    #[serde(with = "simple_date_format")]
    pub timestamp: DateTime<Utc>,
}

/// An environmental sample from one measurement station ready for upload.
/// It can contain values from multiple onboard sensors.
#[derive(Serialize, Debug)]
pub struct SampleUpload {
    /// software name and version
    pub software_version: String,
    /// Datapoints from multiple sensors
    pub sensordatavalues: Vec<SensorDataValue>,
}

/// [de]serialize date to/from string
mod simple_date_format {
    use chrono::{DateTime, NaiveDateTime, Utc};
    use serde::{self, Deserialize, Deserializer};
    const FORMAT: &str = "%Y-%m-%d %H:%M:%S";
    pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        let dt = NaiveDateTime::parse_from_str(&s, FORMAT).map_err(serde::de::Error::custom)?;
        Ok(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
    }
}

/**
    Fetch sensor data using a query.

    **Warning**: this can return tenths of megabytes of data and/or consume significant resources on the server.

    Supported values:
     - `type={sensor type}`: comma-separated list of sensor types, e.g. SDS011,BME280
     - `area={lat,lon,distance}`: all sensors within a max radius e.g. 52.5200,13.4050,10
     - `box={lat1,lon1,lat2,lon2}`: all sensors in a 'box' with the given coordinates e.g. 52.1,13.0,53.5,13.5
     - `country={country code}`: comma-separated list of country codes. Example BE,DE,NL
*/
pub async fn fetch_by_query(q: &str, contact: &str) -> Result<Vec<Sample>, FetchError> {
    const URL: &str = "https://data.sensor.community/airrohr/v1/filter/";
    let url = format!("{URL}{q}");
    let client = Client::new();
    let resp = client.get(url).header("User-Agent", contact).send().await?;
    Ok(resp.json::<Vec<Sample>>().await?)
}

/** Fetch recent data in large batches.

    **Warning**: this can return tenths of megabytes of data!

    The `selector` argument can have values: "", "1h", "24h", "dust.min", "temp.min"
    ```no_run
       let x = fetch_batch("1h").await.unwrap();  // fetch data from the last hour
    ```
*/
pub async fn fetch_batch(selector: &str, contact: &str) -> Result<Vec<Sample>, FetchError> {
    let valid = ["", "1h", "24h", "dust.min", "temp.min"];
    if !valid.contains(&selector) {
        return Err(FetchError::UnsupportedError());
    }
    let url = {
        if selector.is_empty() {
            format!("{URL}/static/v2/data.json")
        } else {
            format!("{URL}/static/v2/data.{selector}.json")
        }
    };
    let client = Client::new();
    let resp = client.get(url).header("User-Agent", contact).send().await?;
    Ok(resp.json::<Vec<Sample>>().await?)
}

/**
    Fetch all measurements of the last 5 minutes for one sensor
    The `API-ID` can be found by clicking on your sensor on the map. This is not the `chipID`.
    ```no_run
        let x = sensor_community::fetch_by_sensor(87420).await.unwrap();
    ```
*/
pub async fn fetch_by_sensor(api_id: u64, contact: &str) -> Result<Vec<Sample>, FetchError> {
    let url = format!("https://data.sensor.community/airrohr/v1/sensor/{api_id}/");
    let client = Client::new();
    let resp = client.get(url).header("User-Agent", contact).send().await?;
    Ok(resp.json::<Vec<Sample>>().await?)
}

/**
    Upload sensor readings from a measurement station.
    * `station_uniq_id`: StationUniqID sometimes called ChipID or Sensor UID e.g. raspi-12345
    * `pin`: Sensor type identification e.g. 1 for SDS011
    * `sample`: [`SampleUpload`]
    * `contact`: Contact point e.g. email address
    ```no_run
    let su = SampleUpload {
        software_version: "rust-sensor-community-0.0.0".to_string(),
        sensordatavalues: vec![SensorDataValue {
            id: None,
            value_type: "P2".to_string(),
            value: serde_json::Value::String("3.22".to_string()),
        }],
    };
    println!("sending data: {}", serde_json::to_string(&su).unwrap());
    let r = sensor_community::upload("raspi-12345", 1, su, "foo@example.com").await;
    println!("{:?}", r);
    ```
*/
pub async fn upload(
    station_uinq_id: &str,
    pin: u32,
    sample: SampleUpload,
    contact: &str,
) -> Result<(), FetchError> {
    const URL: &str = "https://api.sensor.community/v1/push-sensor-data/";
    let client = Client::new();
    let resp = client
        .post(URL)
        .header("User-Agent", contact)
        .header("X-PIN", pin)
        .header("X-Sensor", station_uinq_id)
        .json(&sample)
        .send()
        .await?;
    Ok(resp.json().await?)
}