weather: now, the next 24 hours and the week
git clone https://git.lucas.co/cce-weather.git
src/api.rs (6.6K)
1 //! Open-Meteo: the forecast and the geocoder. Both are keyless HTTPS JSON
2 //! endpoints, called from worker threads (blocking reqwest) — never from the
3 //! frame loop.
4
5 use serde::{Deserialize, Serialize};
6
7 use crate::config::{Location, Units};
8
9 const FORECAST_URL: &str = "https://api.open-meteo.com/v1/forecast";
10 const GEOCODE_URL: &str = "https://geocoding-api.open-meteo.com/v1/search";
11 const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
12
13 /// The fields asked for, per block. Each name is also a key of the response
14 /// struct below, so the two lists move together.
15 const CURRENT: &str = "temperature_2m,apparent_temperature,relative_humidity_2m,is_day,\
16 weather_code,wind_speed_10m,wind_direction_10m,precipitation";
17 const HOURLY: &str = "temperature_2m,precipitation_probability,weather_code,is_day";
18 const DAILY: &str = "weather_code,temperature_2m_max,temperature_2m_min,\
19 precipitation_probability_max,sunrise,sunset";
20
21 #[derive(Debug, Clone, Serialize, Deserialize)]
22 pub struct Forecast {
23 /// Local times are in this zone (`timezone=auto`); every time string in
24 /// the response is local wall-clock time without an offset.
25 pub timezone: String,
26 pub utc_offset_seconds: i32,
27 pub current: Current,
28 pub hourly: Hourly,
29 pub daily: Daily,
30 /// Units the request asked for, so a cached forecast is never shown
31 /// under the other unit's suffix.
32 #[serde(default)]
33 pub units: Units,
34 }
35
36 #[derive(Debug, Clone, Serialize, Deserialize)]
37 pub struct Current {
38 pub time: String,
39 pub temperature_2m: f32,
40 pub apparent_temperature: f32,
41 pub relative_humidity_2m: f32,
42 pub is_day: u8,
43 pub weather_code: u8,
44 pub wind_speed_10m: f32,
45 pub wind_direction_10m: f32,
46 pub precipitation: f32,
47 }
48
49 #[derive(Debug, Clone, Serialize, Deserialize)]
50 pub struct Hourly {
51 pub time: Vec<String>,
52 pub temperature_2m: Vec<Option<f32>>,
53 pub precipitation_probability: Vec<Option<f32>>,
54 pub weather_code: Vec<Option<u8>>,
55 pub is_day: Vec<Option<u8>>,
56 }
57
58 #[derive(Debug, Clone, Serialize, Deserialize)]
59 pub struct Daily {
60 pub time: Vec<String>,
61 pub weather_code: Vec<Option<u8>>,
62 pub temperature_2m_max: Vec<Option<f32>>,
63 pub temperature_2m_min: Vec<Option<f32>>,
64 pub precipitation_probability_max: Vec<Option<f32>>,
65 pub sunrise: Vec<String>,
66 pub sunset: Vec<String>,
67 }
68
69 fn client() -> Result<reqwest::blocking::Client, String> {
70 reqwest::blocking::Client::builder()
71 .timeout(TIMEOUT)
72 .user_agent(concat!("cce-weather/", env!("CARGO_PKG_VERSION")))
73 .build()
74 .map_err(|e| e.to_string())
75 }
76
77 pub fn fetch_forecast(loc: &Location, units: Units) -> Result<Forecast, String> {
78 let lat = format!("{:.4}", loc.latitude);
79 let lon = format!("{:.4}", loc.longitude);
80 let mut query: Vec<(&str, &str)> = vec![
81 ("latitude", &lat),
82 ("longitude", &lon),
83 ("current", CURRENT),
84 ("hourly", HOURLY),
85 ("daily", DAILY),
86 ("timezone", "auto"),
87 ("forecast_days", "7"),
88 ];
89 if units == Units::Imperial {
90 query.extend([
91 ("temperature_unit", "fahrenheit"),
92 ("wind_speed_unit", "mph"),
93 ("precipitation_unit", "inch"),
94 ]);
95 }
96 let resp = client()?
97 .get(FORECAST_URL)
98 .query(&query)
99 .send()
100 .map_err(|e| format!("network: {e}"))?;
101 if !resp.status().is_success() {
102 return Err(format!("forecast: HTTP {}", resp.status()));
103 }
104 let mut f: Forecast = resp.json().map_err(|e| format!("forecast: {e}"))?;
105 f.units = units;
106 Ok(f)
107 }
108
109 #[derive(Deserialize)]
110 struct GeoResponse {
111 #[serde(default)]
112 results: Vec<GeoResult>,
113 }
114
115 #[derive(Deserialize)]
116 struct GeoResult {
117 name: String,
118 latitude: f64,
119 longitude: f64,
120 #[serde(default)]
121 admin1: Option<String>,
122 #[serde(default)]
123 country: Option<String>,
124 }
125
126 /// Up to `count` places matching a free-text name, best match first.
127 pub fn geocode(query: &str, count: usize) -> Result<Vec<Location>, String> {
128 let count = count.to_string();
129 let resp = client()?
130 .get(GEOCODE_URL)
131 .query(&[("name", query), ("count", &count), ("format", "json")])
132 .send()
133 .map_err(|e| format!("network: {e}"))?;
134 if !resp.status().is_success() {
135 return Err(format!("search: HTTP {}", resp.status()));
136 }
137 let geo: GeoResponse = resp.json().map_err(|e| format!("search: {e}"))?;
138 Ok(geo
139 .results
140 .into_iter()
141 .map(|r| Location {
142 name: r.name,
143 region: [r.admin1, r.country].into_iter().flatten().collect::<Vec<_>>().join(", "),
144 latitude: r.latitude,
145 longitude: r.longitude,
146 })
147 .collect())
148 }
149
150 #[cfg(test)]
151 mod tests {
152 use super::*;
153
154 /// A trimmed real response: the shape serde must accept, including the
155 /// nulls Open-Meteo sends past the end of a model's horizon.
156 const SAMPLE: &str = r#"{
157 "latitude": 40.71, "longitude": -74.0, "timezone": "America/New_York",
158 "utc_offset_seconds": -14400,
159 "current": {"time": "2026-10-05T14:00", "interval": 900, "temperature_2m": 18.4,
160 "apparent_temperature": 17.1, "relative_humidity_2m": 61, "is_day": 1,
161 "weather_code": 2, "wind_speed_10m": 11.2, "wind_direction_10m": 225,
162 "precipitation": 0.0},
163 "hourly": {"time": ["2026-10-05T00:00", "2026-10-05T01:00"],
164 "temperature_2m": [15.0, null], "precipitation_probability": [10, null],
165 "weather_code": [3, null], "is_day": [0, 0]},
166 "daily": {"time": ["2026-10-05"], "weather_code": [61],
167 "temperature_2m_max": [19.2], "temperature_2m_min": [12.0],
168 "precipitation_probability_max": [70],
169 "sunrise": ["2026-10-05T07:01"], "sunset": ["2026-10-05T18:37"]}
170 }"#;
171
172 #[test]
173 fn parses_forecast_with_nulls() {
174 let f: Forecast = serde_json::from_str(SAMPLE).unwrap();
175 assert_eq!(f.current.weather_code, 2);
176 assert_eq!(f.hourly.temperature_2m[1], None);
177 assert_eq!(f.daily.precipitation_probability_max[0], Some(70.0));
178 assert_eq!(f.units, Units::Metric);
179 }
180
181 /// Against the real service: `cargo test -p cce-weather -- --ignored`.
182 #[test]
183 #[ignore]
184 fn live_geocode_and_forecast() {
185 let places = geocode("Brooklyn", 3).unwrap();
186 assert!(!places.is_empty());
187 let f = fetch_forecast(&places[0], Units::Imperial).unwrap();
188 assert!(f.hourly.time.len() >= 24 * 7);
189 assert_eq!(f.daily.time.len(), 7);
190 assert_eq!(f.units, Units::Imperial);
191 }
192 }