use async_trait::async_trait;
use serde::Deserialize;
use crate::options::OptionSchema;
use crate::payload::{
Bar, BarsData, Body, EntriesData, Entry, NumberSeriesData, Payload, PointSeries,
PointSeriesData, TextData,
};
use crate::render::Shape;
use super::super::github::common::cache_key;
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::common::{
API_BASE, Units, http, parse_options, payload, weather_badge, weather_description,
};
use super::sprites::sprite_for_code;
const HOURLY_HOURS: usize = 24;
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "latitude",
type_hint: "float (degrees)",
required: true,
default: None,
description: "Latitude of the location to query (e.g., `35.68` for Tokyo).",
},
OptionSchema {
name: "longitude",
type_hint: "float (degrees)",
required: true,
default: None,
description: "Longitude of the location to query (e.g., `139.76` for Tokyo).",
},
OptionSchema {
name: "units",
type_hint: "\"metric\" | \"imperial\"",
required: false,
default: Some("\"metric\""),
description: "Temperature and wind unit system. Metric renders °C / m/s; imperial renders °F / mph.",
},
];
pub struct WeatherFetcher;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WeatherOptions {
pub latitude: f64,
pub longitude: f64,
#[serde(default)]
pub units: Option<Units>,
}
#[async_trait]
impl Fetcher for WeatherFetcher {
fn name(&self) -> &str {
"weather"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Forecast for a fixed (latitude, longitude) via Open-Meteo. `Entries` / `Text` summarise current conditions; `PointSeries` carries the next 24h of hourly temperature (signed °C / °F); `NumberSeries` / `Bars` carry hourly precipitation in tenths of mm/inch; `Badge` flags severe weather codes (thunderstorm = error, freezing / snow / heavy rain = warn); `PixelArt` paints a 16×16 truecolor sprite of the current sky. Metric or imperial units, no API key required."
}
fn refresh_interval(&self) -> u64 {
60 * 30
}
fn shapes(&self) -> &[Shape] {
&[
Shape::Entries,
Shape::Text,
Shape::PointSeries,
Shape::NumberSeries,
Shape::Bars,
Shape::Badge,
Shape::PixelArt,
]
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
let extra = ctx
.options
.as_ref()
.and_then(|v| toml::to_string(v).ok())
.unwrap_or_default();
cache_key(self.name(), ctx, &extra)
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
let sample = Sample::default();
Some(match shape {
Shape::Entries => entries(&sample),
Shape::Text => Body::Text(TextData {
value: sample.as_text(),
}),
Shape::PointSeries => point_series(&sample.hourly, sample.units),
Shape::NumberSeries => number_series(&sample.hourly),
Shape::Bars => precipitation_bars(&sample.hourly),
Shape::Badge => Body::Badge(weather_badge(sample.code)),
Shape::PixelArt => Body::PixelArt(sprite_for_code(sample.code)),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: WeatherOptions =
parse_options(ctx.options.as_ref(), self.name()).map_err(FetchError::Failed)?;
let units = opts.units.unwrap_or_default();
let report = fetch_report(opts.latitude, opts.longitude, units).await?;
let body = match ctx.shape.unwrap_or(Shape::Entries) {
Shape::Text => Body::Text(TextData {
value: report.as_text(),
}),
Shape::PointSeries => point_series(&report.hourly, report.units),
Shape::NumberSeries => number_series(&report.hourly),
Shape::Bars => precipitation_bars(&report.hourly),
Shape::Badge => Body::Badge(weather_badge(report.code)),
Shape::PixelArt => Body::PixelArt(sprite_for_code(report.code)),
_ => entries(&report),
};
Ok(payload(body))
}
}
async fn fetch_report(lat: f64, lon: f64, units: Units) -> Result<Sample, FetchError> {
let url = build_url(lat, lon, units);
let res = http()
.get(&url)
.send()
.await
.map_err(|e| FetchError::Failed(format!("weather request failed: {e}")))?;
let status = res.status();
if !status.is_success() {
return Err(FetchError::Failed(format!("weather {status}")));
}
let raw: ApiResponse = res
.json()
.await
.map_err(|e| FetchError::Failed(format!("weather json parse: {e}")))?;
Ok(Sample {
code: raw.current.weather_code,
temperature: raw.current.temperature_2m,
wind_speed: raw.current.wind_speed_10m,
humidity: raw.current.relative_humidity_2m,
hourly: hourly_from(&raw.hourly),
units,
})
}
fn hourly_from(raw: &Hourly) -> Vec<HourPoint> {
let temps = raw.temperature_2m.iter().copied();
let precs = raw
.precipitation
.iter()
.copied()
.chain(std::iter::repeat(0.0));
temps
.zip(precs)
.take(HOURLY_HOURS)
.enumerate()
.map(|(i, (temp, precip))| HourPoint {
offset_hours: i as u32,
temperature: temp,
precipitation: precip,
})
.collect()
}
fn build_url(lat: f64, lon: f64, units: Units) -> String {
let base = format!(
"{API_BASE}?latitude={lat}&longitude={lon}\
¤t=temperature_2m,weather_code,wind_speed_10m,relative_humidity_2m\
&hourly=temperature_2m,precipitation\
&forecast_days=2"
);
match units {
Units::Metric => format!("{base}&wind_speed_unit=ms"),
Units::Imperial => format!("{base}&temperature_unit=fahrenheit&wind_speed_unit=mph"),
}
}
#[derive(Debug, Deserialize)]
struct ApiResponse {
current: Current,
#[serde(default)]
hourly: Hourly,
}
#[derive(Debug, Deserialize)]
struct Current {
temperature_2m: f64,
weather_code: u16,
wind_speed_10m: f64,
relative_humidity_2m: u8,
}
#[derive(Debug, Default, Deserialize)]
struct Hourly {
#[serde(default)]
temperature_2m: Vec<f64>,
#[serde(default)]
precipitation: Vec<f64>,
}
struct Sample {
code: u16,
temperature: f64,
wind_speed: f64,
humidity: u8,
hourly: Vec<HourPoint>,
units: Units,
}
#[derive(Debug, Clone, Copy)]
struct HourPoint {
offset_hours: u32,
temperature: f64,
precipitation: f64,
}
impl Default for Sample {
fn default() -> Self {
let hourly = (0..HOURLY_HOURS as u32)
.map(|i| HourPoint {
offset_hours: i,
temperature: 16.0 + ((i as f64) * 0.4 - (i as f64 / 8.0).sin() * 4.0),
precipitation: if (10..=14).contains(&i) { 0.6 } else { 0.0 },
})
.collect();
Self {
code: 3,
temperature: 18.0,
wind_speed: 4.0,
humidity: 67,
hourly,
units: Units::Metric,
}
}
}
impl Sample {
fn temperature_label(&self) -> String {
format!("{:.0}{}", self.temperature, self.units.temperature_label())
}
fn wind_label(&self) -> String {
format!("{:.0} {}", self.wind_speed, self.units.wind_label())
}
fn humidity_label(&self) -> String {
format!("{}%", self.humidity)
}
fn as_text(&self) -> String {
let (emoji, condition) = weather_description(self.code);
format!(
"{emoji} {condition} · {} · 💨 {} · 💧 {}",
self.temperature_label(),
self.wind_label(),
self.humidity_label(),
)
}
}
fn point_series(hourly: &[HourPoint], units: Units) -> Body {
Body::PointSeries(PointSeriesData {
series: vec![PointSeries {
name: format!("temperature ({})", units.temperature_label()),
points: hourly
.iter()
.map(|h| (f64::from(h.offset_hours), h.temperature))
.collect(),
}],
})
}
fn number_series(hourly: &[HourPoint]) -> Body {
Body::NumberSeries(NumberSeriesData {
values: hourly
.iter()
.map(|h| (h.precipitation.max(0.0) * 10.0).round() as u64)
.collect(),
})
}
fn precipitation_bars(hourly: &[HourPoint]) -> Body {
Body::Bars(BarsData {
bars: hourly
.iter()
.map(|h| Bar {
label: format!("+{}h", h.offset_hours),
value: (h.precipitation.max(0.0) * 10.0).round() as u64,
value_label: None,
})
.collect(),
})
}
fn entries(s: &Sample) -> Body {
let (emoji, condition) = weather_description(s.code);
let rows = [
(format!("{emoji} {condition}"), s.temperature_label()),
("💨 wind".into(), s.wind_label()),
("💧 humidity".into(), s.humidity_label()),
];
Body::Entries(EntriesData {
items: rows
.into_iter()
.map(|(k, v)| Entry {
key: k,
value: Some(v),
status: None,
})
.collect(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payload::{BadgeData, Status};
fn entries_data(body: Body) -> Option<EntriesData> {
match body {
Body::Entries(data) => Some(data),
_ => None,
}
}
fn point_series_data(body: Body) -> Option<PointSeriesData> {
match body {
Body::PointSeries(data) => Some(data),
_ => None,
}
}
fn number_series_data(body: Body) -> Option<NumberSeriesData> {
match body {
Body::NumberSeries(data) => Some(data),
_ => None,
}
}
fn bars_data(body: Body) -> Option<BarsData> {
match body {
Body::Bars(data) => Some(data),
_ => None,
}
}
fn text_data(body: Body) -> Option<TextData> {
match body {
Body::Text(data) => Some(data),
_ => None,
}
}
fn badge_data(body: Body) -> Option<BadgeData> {
match body {
Body::Badge(data) => Some(data),
_ => None,
}
}
#[test]
fn build_url_metric_requests_ms_wind() {
let url = build_url(35.68, 139.76, Units::Metric);
assert!(url.contains("latitude=35.68"));
assert!(url.contains("longitude=139.76"));
assert!(url.contains("wind_speed_unit=ms"));
assert!(!url.contains("fahrenheit"));
assert!(!url.contains("mph"));
}
#[test]
fn build_url_imperial_sets_fahrenheit_and_mph() {
let url = build_url(40.71, -74.0, Units::Imperial);
assert!(url.contains("temperature_unit=fahrenheit"));
assert!(url.contains("wind_speed_unit=mph"));
}
#[test]
fn sample_entries_expose_three_rows() {
let e = entries_data(entries(&Sample::default())).unwrap();
assert_eq!(e.items.len(), 3);
assert!(e.items[0].value.as_deref().unwrap().ends_with("°C"));
assert!(e.items[1].value.as_deref().unwrap().ends_with("m/s"));
assert!(e.items[2].value.as_deref().unwrap().ends_with('%'));
}
#[test]
fn imperial_units_render_fahrenheit_and_mph() {
let s = Sample {
units: Units::Imperial,
..Sample::default()
};
assert!(s.temperature_label().ends_with("°F"));
assert!(s.wind_label().ends_with("mph"));
}
#[test]
fn api_response_deserializes_open_meteo_current() {
let raw = r#"{"current":{"time":"2026-04-23T12:00","temperature_2m":18.2,"weather_code":3,"wind_speed_10m":4.1,"relative_humidity_2m":67}}"#;
let parsed: ApiResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.current.weather_code, 3);
assert_eq!(parsed.current.relative_humidity_2m, 67);
}
#[test]
fn parse_options_requires_latitude_and_longitude() {
let err: String = parse_options::<WeatherOptions>(None, "weather").unwrap_err();
assert!(err.contains("latitude"));
}
#[test]
fn parse_options_accepts_floats() {
let raw: toml::Value = toml::from_str("latitude = 35.68\nlongitude = 139.76").unwrap();
let opts: WeatherOptions = parse_options(Some(&raw), "weather").unwrap();
assert_eq!(opts.latitude, 35.68);
assert_eq!(opts.longitude, 139.76);
}
#[test]
fn parse_options_rejects_unknown_keys() {
let raw: toml::Value =
toml::from_str("latitude = 1.0\nlongitude = 2.0\nbogus = true").unwrap();
assert!(parse_options::<WeatherOptions>(Some(&raw), "weather").is_err());
}
#[test]
fn point_series_carries_one_temperature_series_with_24_points() {
let d = point_series_data(point_series(&Sample::default().hourly, Units::Metric)).unwrap();
assert_eq!(d.series.len(), 1);
assert_eq!(d.series[0].points.len(), HOURLY_HOURS);
assert!(d.series[0].name.contains("°C"));
}
#[test]
fn number_series_carries_precipitation_in_tenths() {
let hourly = vec![
HourPoint {
offset_hours: 0,
temperature: -3.0,
precipitation: 0.6,
},
HourPoint {
offset_hours: 1,
temperature: -1.0,
precipitation: 0.0,
},
HourPoint {
offset_hours: 2,
temperature: 1.0,
precipitation: 1.25,
},
];
let d = number_series_data(number_series(&hourly)).unwrap();
assert_eq!(d.values, vec![6, 0, 13]);
}
#[test]
fn point_series_temperature_preserves_negative_readings() {
let hourly = vec![
HourPoint {
offset_hours: 0,
temperature: -5.0,
precipitation: 0.0,
},
HourPoint {
offset_hours: 1,
temperature: -2.5,
precipitation: 0.0,
},
];
let d = point_series_data(point_series(&hourly, Units::Metric)).unwrap();
assert_eq!(d.series[0].points[0].1, -5.0);
assert_eq!(d.series[0].points[1].1, -2.5);
}
#[test]
fn precipitation_bars_are_labelled_with_hour_offsets() {
let d = bars_data(precipitation_bars(&Sample::default().hourly)).unwrap();
assert_eq!(d.bars.len(), HOURLY_HOURS);
assert_eq!(d.bars[0].label, "+0h");
assert_eq!(d.bars[5].label, "+5h");
}
#[test]
fn fetcher_metadata_cache_key_and_samples_cover_supported_shapes() {
let fetcher = WeatherFetcher;
let ctx = FetchContext {
widget_id: "weather".into(),
timeout: std::time::Duration::from_secs(1),
shape: Some(Shape::Entries),
..Default::default()
};
let with_options = FetchContext {
options: Some(
toml::from_str("latitude = 35.68\nlongitude = 139.76\nunits = \"imperial\"")
.unwrap(),
),
..ctx.clone()
};
assert_eq!(fetcher.name(), "weather");
assert_eq!(fetcher.safety(), Safety::Safe);
assert!(fetcher.description().contains("Open-Meteo"));
assert_eq!(
fetcher
.option_schemas()
.iter()
.map(|schema| schema.name)
.collect::<Vec<_>>(),
vec!["latitude", "longitude", "units"]
);
assert_eq!(
fetcher.shapes(),
[
Shape::Entries,
Shape::Text,
Shape::PointSeries,
Shape::NumberSeries,
Shape::Bars,
Shape::Badge,
Shape::PixelArt,
]
.as_slice()
);
assert_ne!(fetcher.cache_key(&ctx), fetcher.cache_key(&with_options));
for &shape in fetcher.shapes() {
let body = fetcher.sample_body(shape).unwrap();
assert!(matches!(
(shape, body),
(Shape::Entries, Body::Entries(_))
| (Shape::Text, Body::Text(_))
| (Shape::PointSeries, Body::PointSeries(_))
| (Shape::NumberSeries, Body::NumberSeries(_))
| (Shape::Bars, Body::Bars(_))
| (Shape::Badge, Body::Badge(_))
| (Shape::PixelArt, Body::PixelArt(_))
));
}
let text = fetcher
.sample_body(Shape::Text)
.and_then(text_data)
.unwrap();
let series = fetcher
.sample_body(Shape::PointSeries)
.and_then(point_series_data)
.unwrap();
let badge = fetcher
.sample_body(Shape::Badge)
.and_then(badge_data)
.unwrap();
assert!(text.value.contains("💨"));
assert_eq!(series.series[0].name, "temperature (°C)");
assert_eq!(badge.status, Status::Ok);
assert!(fetcher.sample_body(Shape::Calendar).is_none());
}
#[test]
fn helper_paths_cover_padding_and_singletons() {
let points = hourly_from(&Hourly {
temperature_2m: (0..30).map(|i| i as f64).collect(),
precipitation: vec![0.5],
});
let series = point_series_data(point_series(&points[..2], Units::Imperial)).unwrap();
let text = text_data(payload(Body::Text(TextData { value: "ok".into() })).body).unwrap();
assert_eq!(points.len(), HOURLY_HOURS);
assert_eq!(points[1].precipitation, 0.0);
assert_eq!(points[23].offset_hours, 23);
assert_eq!(series.series[0].name, "temperature (°F)");
assert_eq!(text.value, "ok");
}
#[test]
fn body_extractors_return_none_for_wrong_variants() {
assert!(entries_data(Body::Text(TextData { value: "x".into() })).is_none());
assert!(point_series_data(entries(&Sample::default())).is_none());
assert!(number_series_data(Body::Bars(BarsData { bars: vec![] })).is_none());
assert!(bars_data(Body::NumberSeries(NumberSeriesData { values: vec![] })).is_none());
assert!(text_data(Body::Badge(weather_badge(0))).is_none());
assert!(badge_data(Body::Text(TextData { value: "x".into() })).is_none());
}
#[tokio::test]
async fn fetch_rejects_missing_options_before_network() {
let ctx = FetchContext {
widget_id: "weather".into(),
timeout: std::time::Duration::from_secs(1),
shape: Some(Shape::Text),
..Default::default()
};
let err = WeatherFetcher.fetch(&ctx).await.unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("latitude")));
}
#[tokio::test]
#[ignore]
async fn live_tokyo_now_populates_entries() {
let report = fetch_report(35.68, 139.76, Units::Metric).await.unwrap();
let body = entries(&report);
let Body::Entries(e) = body else {
panic!("expected entries");
};
assert_eq!(e.items.len(), 3);
for row in &e.items {
eprintln!("{} → {}", row.key, row.value.as_deref().unwrap_or(""));
}
}
}