fjordgard

A desktop clock application
Log | Files | Refs | README | LICENSE

commit f3ce23261f6d0a110371eb5b3c7b4cba4878a5a9
parent 1939e9b2049a806e877f2852034597b2eabf87cc
Author: Sylvia Ivory <git@sivory.net>
Date:   Sun, 15 Jun 2025 22:04:50 -0700

Add forecast api

Diffstat:
MCargo.lock | 30++++++++++++++++++++++++++++++
Mcrates/weather/Cargo.toml | 2++
Mcrates/weather/src/error.rs | 2--
Mcrates/weather/src/lib.rs | 73++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcrates/weather/src/model.rs | 292++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
5 files changed, 367 insertions(+), 32 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -149,6 +149,8 @@ version = "0.1.0" dependencies = [ "reqwest", "serde", + "serde_json", + "strum", "thiserror", "tokio", ] @@ -277,6 +279,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" [[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] name = "http" version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -961,6 +969,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] +name = "strum" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/crates/weather/Cargo.toml b/crates/weather/Cargo.toml @@ -6,6 +6,8 @@ edition = "2024" [dependencies] reqwest = { version = "0.12.20", features = ["json"] } serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" +strum = { version = "0.27.1", features = ["derive"] } thiserror = "2.0.12" [dev-dependencies] diff --git a/crates/weather/src/error.rs b/crates/weather/src/error.rs @@ -4,8 +4,6 @@ pub enum Error { Reqwest(#[from] reqwest::Error), #[error("meteo error: {0}")] Meteo(String), - #[error("meteo returned nothing")] - MeteoEmpty, } pub type Result<T, E = Error> = std::result::Result<T, E>; diff --git a/crates/weather/src/lib.rs b/crates/weather/src/lib.rs @@ -9,8 +9,9 @@ use serde::{Serialize, de::DeserializeOwned}; mod error; mod model; -static USER_AGENT: &str = concat!("fjordgard/", env!("CARGO_PKG_VERSION")); +const USER_AGENT: &str = concat!("fjordgard/", env!("CARGO_PKG_VERSION")); const GEOCODING_API_HOST: &str = "geocoding-api.open-meteo.com"; +const FORECASTING_API_HOST: &str = "api.open-meteo.com"; pub struct MeteoClient { api_key: Option<String>, @@ -56,14 +57,9 @@ impl MeteoClient { let resp: MeteoResponse<T> = req.send().await?.json().await?; - if resp.error.unwrap_or_default() { - return Err(Error::Meteo(resp.reason.unwrap_or_default())); - } - - if let Some(res) = resp.results { - Ok(res) - } else { - Err(Error::MeteoEmpty) + match resp { + MeteoResponse::Success(s) => Ok(s), + MeteoResponse::Error { error: _, reason } => Err(Error::Meteo(reason)), } } @@ -72,16 +68,28 @@ impl MeteoClient { &self, name: &str, opt: Option<GeocodeOptions>, - ) -> Result<Vec<GeocodeResponse>> { - let resp = self + ) -> Result<Vec<GeocodeResult>> { + let resp: GeocodeResponse = self .request(GEOCODING_API_HOST, "search", Some(&[("name", name)]), opt) - .await; + .await?; - match resp { - Err(Error::MeteoEmpty) => Ok(vec![]), - Err(e) => Err(e), - Ok(o) => Ok(o), - } + Ok(resp.results) + } + + /// Endpoint: `/forecast` + pub async fn forecast_single( + &self, + latitude: f64, + longitude: f64, + opt: Option<ForecastOptions>, + ) -> Result<ForecastResponse> { + self.request( + FORECASTING_API_HOST, + "forecast", + Some(&[("latitude", latitude), ("longitude", longitude)]), + opt, + ) + .await } } @@ -89,17 +97,40 @@ impl MeteoClient { mod tests { use super::*; - #[tokio::test] - async fn geocode() { - let client = MeteoClient::new(None).unwrap(); + async fn get_london(client: &MeteoClient) -> GeocodeResult { let res = client .geocode("London, United Kingdom", None) .await .unwrap(); - let london = res.get(0).unwrap(); + + res.get(0).unwrap().clone() + } + + #[tokio::test] + async fn geocode() { + let client = MeteoClient::new(None).unwrap(); + let london = get_london(&client).await; assert_eq!(london.timezone, "Europe/London"); assert_eq!(london.admin1, Some("England".to_string())); assert_eq!(london.country, "United Kingdom"); } + + #[tokio::test] + async fn forecast_single() { + let client = MeteoClient::new(None).unwrap(); + let london = get_london(&client).await; + + client + .forecast_single( + london.latitude, + london.longitude, + Some(ForecastOptions { + hourly: Some(vec![HourlyVariable::Temperature2m]), + ..Default::default() + }), + ) + .await + .unwrap(); + } } diff --git a/crates/weather/src/model.rs b/crates/weather/src/model.rs @@ -1,23 +1,25 @@ -use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fmt::Display}; + +use serde::{Deserialize, Serialize, Serializer}; #[derive(Deserialize, Debug)] -pub(crate) struct MeteoResponse<T> { - pub(crate) results: Option<T>, - pub(crate) error: Option<bool>, - pub(crate) reason: Option<String>, +#[serde(untagged)] +pub(crate) enum MeteoResponse<T> { + Success(T), + Error { error: bool, reason: String }, } -#[derive(Serialize)] +#[derive(Serialize, Default)] #[serde(default)] pub struct GeocodeOptions { pub count: Option<usize>, pub language: Option<String>, - pub api_key: Option<String>, + #[serde(rename = "countryCode")] pub country_code: Option<String>, } -#[derive(Deserialize, Debug)] -pub struct GeocodeResponse { +#[derive(Deserialize, Debug, Clone)] +pub struct GeocodeResult { pub id: usize, pub name: String, pub latitude: f64, @@ -49,3 +51,275 @@ pub struct GeocodeResponse { #[serde(default)] pub admin4_id: Option<usize>, } + +#[derive(Deserialize, Debug, Clone)] +pub struct GeocodeResponse { + pub results: Vec<GeocodeResult>, + pub generationtime_ms: f64, +} + +#[derive(strum::Display)] +#[strum(serialize_all = "snake_case")] +pub enum HourlyVariable { + #[strum(to_string = "temperature_2m")] + Temperature2m, + #[strum(to_string = "temperature_{0}hPa")] + TemperaturePressureLevel(usize), + #[strum(to_string = "relative_humidity_2m")] + RelativeHumidity2m, + #[strum(to_string = "relative_humidity_{0}hPa")] + RelativeHumidityPressureLevel(usize), + #[strum(to_string = "dew_point_2m")] + DewPoint2m, + #[strum(to_string = "dew_point_{0}hPa")] + DewPointPressureLevel(usize), + ApparentTemperature, + PressureMsl, + SurfacePressure, + CloudCover, + CloudCoverLow, + CloudCoverMid, + CloudCoverHigh, + #[strum(to_string = "cloud_cover_{0}hPa")] + CloudCoverPressureLevel(usize), + #[strum(to_string = "wind_speed_10m")] + WindSpeed10m, + #[strum(to_string = "wind_speed_80m")] + WindSpeed80m, + #[strum(to_string = "wind_speed_120m")] + WindSpeed120m, + #[strum(to_string = "wind_speed_180m")] + WindSpeed180m, + #[strum(to_string = "wind_speed_{0}hPa")] + WindSpeedPressureLevel(usize), + #[strum(to_string = "wind_direction_10m")] + WindDirection10m, + #[strum(to_string = "wind_direction_80m")] + WindDirection80m, + #[strum(to_string = "wind_direction_120m")] + WindDirection120m, + #[strum(to_string = "wind_direction_180m")] + WindDIrection180m, + #[strum(to_string = "wind_direction_{0}hPa")] + WindDirectionPressureLevel(usize), + #[strum(to_string = "wind_gusts_10m")] + WindGusts10m, + ShortwaveRadiation, + DirectRadiation, + DirectNormalIrradiance, + DiffuseRadiation, + GlobalTiltedIrradiance, + VapourPressureDeficit, + Cape, + Evapotranspiration, + Et0FaoEvapotranspiration, + Precipitation, + Snowfall, + PrecipitationProbability, + Rain, + Showers, + WeatherCode, + SnowDepth, + FreezingLevelHeight, + Visibility, + #[strum(to_string = "soil_temperature_0cm")] + SoilTemperature0cm, + #[strum(to_string = "soil_temperature_6cm")] + SoilTemperature6cm, + #[strum(to_string = "soil_temperature_18cm")] + SoilTemperature18cm, + #[strum(to_string = "soil_temperature_54cm")] + SoilTemperature54cm, + #[strum(to_string = "soil_moisture_0_to_1cm")] + SoilMoisture0To1cm, + #[strum(to_string = "soil_moisture_1_to_3cm")] + SoilMoisture1To3cm, + #[strum(to_string = "soil_moisture_3_to_9cm")] + SoilMoisture3To9cm, + #[strum(to_string = "soil_moisture_9_to_27cm")] + SoilMoisture9To27cm, + #[strum(to_string = "soil_moisture_27_to_81cm")] + SoilMoisture28To81cm, + IsDay, + #[strum(to_string = "geopotential_height_{0}hPa")] + GeopotentialHeightPressureLevel(usize), +} + +#[derive(strum::Display)] +#[strum(serialize_all = "snake_case")] +pub enum DailyVariable { + #[strum(to_string = "temperature_2m_max")] + Temperature2mMax, + #[strum(to_string = "temperature_2m_mean")] + Temperature2mMean, + #[strum(to_string = "temperature_2m_min")] + Temperature2mMin, + ApparentTemperatureMax, + ApparentTemperatureMean, + ApparentTemperatureMin, + PrecipitationSum, + RainSum, + ShowersSum, + SnowfallSum, + PrecipitationHours, + PrecipitationProbabilityMax, + PrecipitationProbabilityMean, + PrecipitationProbabilityMin, + WeatherCode, + Sunrise, + Sunset, + SunshineDuration, + DaylightDuration, + #[strum(to_string = "wind_speed_10m_max")] + WindSpeed10mMax, + #[strum(to_string = "wind_gusts_10m_max")] + WindGusts10mMax, + #[strum(to_string = "wind_direction_10m_dominant")] + WindDirection10mDominant, + ShortwaveRadiationSum, + Et0FaoEvapotranspiration, + UvIndexMax, + UvIndexClearSkyMax, +} + +#[derive(strum::Display)] +#[strum(serialize_all = "snake_case")] +pub enum CurrentVariable { + #[strum(to_string = "temperature_2m")] + Temperature2m, + #[strum(to_string = "relative_humidity_2m")] + RelativeHumidity2m, + #[strum(to_string = "dew_point_2m")] + DewPoint2m, + ApparentTemperature, + ShortwaveRadiation, + DirectRadiation, + DirectNormalIrradiance, + GlobalTiltedIrradiance, + GlobalTiltedIrradianceInstant, + DiffuseRadiation, + SunshineDuration, + LightningPotential, + Precipitation, + Snowfall, + Rain, + Showers, + SnowfallHeight, + FreezingLevelHeight, + Cape, + #[strum(to_string = "wind_speed_10m")] + WindSpeed10m, + #[strum(to_string = "wind_speed_80m")] + WindSpeed80m, + #[strum(to_string = "wind_direction_10m")] + WindDirection10m, + #[strum(to_string = "wind_direction_80m")] + WindDirection80m, + #[strum(to_string = "wind_gusts_10m")] + WindGusts10m, + Visibility, + WeatherCode, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TemperatureUnit { + Celsius, + Fahrenheit, +} + +#[derive(Serialize)] +pub enum SpeedUnit { + #[serde(rename = "kmh")] + KilometersPerHour, + #[serde(rename = "ms")] + MetersPerSecond, + #[serde(rename = "mph")] + MilesPerHour, + #[serde(rename = "kn")] + Knots, +} + +#[derive(Serialize)] +pub enum PrecipitationUnit { + #[serde(rename = "mm")] + Millimeter, + #[serde(rename = "inch")] + Inch, +} + +#[derive(Serialize)] +#[serde(rename = "lowercase")] +pub enum TimeFormat { + Iso8601, + UnixTime, +} + +#[derive(Serialize)] +#[serde(rename = "lowercase")] +pub enum CellSelection { + Land, + Sea, + Nearest, +} + +#[derive(Serialize, Default)] +pub struct ForecastOptions { + pub elevation: Option<f64>, + #[serde(serialize_with = "csv")] + pub hourly: Option<Vec<HourlyVariable>>, + #[serde(serialize_with = "csv")] + pub daily: Option<Vec<DailyVariable>>, + #[serde(serialize_with = "csv")] + pub current: Option<Vec<CurrentVariable>>, + pub temperature_unit: Option<TemperatureUnit>, + pub wind_speed_unit: Option<SpeedUnit>, + pub precipitation_unit: Option<PrecipitationUnit>, + pub time_format: Option<TimeFormat>, + pub timezone: Option<String>, + pub past_days: Option<usize>, + pub past_hours: Option<usize>, + pub past_minutely_15: Option<usize>, + pub forecast_days: Option<usize>, + pub forecast_hours: Option<usize>, + pub forecast_minutely_15: Option<usize>, + pub start_date: Option<String>, + pub end_date: Option<String>, + pub start_hour: Option<String>, + pub end_hour: Option<String>, + pub start_minutely_15: Option<String>, + pub end_minutely_15: Option<String>, + #[serde(serialize_with = "csv")] + pub models: Option<Vec<String>>, + pub cell_selection: Option<CellSelection>, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct ForecastResponse { + pub latitude: f64, + pub longitude: f64, + pub elevation: f64, + #[serde(rename = "generationtime_ms")] + pub generation_time_ms: f64, + pub utc_offset_seconds: isize, + pub timezone: String, + pub timezone_abbreviation: String, + pub hourly: Option<HashMap<String, serde_json::Value>>, + pub hourly_units: Option<HashMap<String, String>>, + pub daily: Option<HashMap<String, serde_json::Value>>, + pub daily_units: Option<HashMap<String, String>>, +} + +fn csv<S: Serializer, T: Display>(list: &Option<Vec<T>>, serializer: S) -> Result<S::Ok, S::Error> { + if let Some(list) = list { + let s: String = list + .iter() + .map(|v| v.to_string()) + .collect::<Vec<String>>() + .join(","); + + serializer.serialize_str(&s) + } else { + serializer.serialize_none() + } +}