123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 | //! Weather via wttr.in (no API key; IP-based geolocation unless the
//! weather widget's `location` option is set). Fetched with curl every
//! 15 minutes, emitted as `divebar://weather` events, and re-fetched when
//! the config changes location.
use crate::events::Trigger;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
use tauri::AppHandle;
const FETCH_INTERVAL: Duration = Duration::from_secs(15 * 60);
const RETRY_INTERVAL: Duration = Duration::from_secs(60);
static REFRESH: Trigger = Trigger::new();
/// Wakes the weather thread (e.g. after a config save, in case the
/// location option changed). It only refetches if the location actually
/// differs or the data is stale.
pub fn request_refresh() {
REFRESH.notify();
}
/// The `location` option of the first weather widget in the config.
fn configured_location() -> String {
let config = crate::config::load();
config["widgets"]
.as_array()
.and_then(|widgets| {
widgets
.iter()
.find(|w| w["widget"] == "weather")
.and_then(|w| w["options"]["location"].as_str())
})
.unwrap_or("")
.trim()
.to_string()
}
pub fn spawn_weather(app: AppHandle) {
let mut last_location: Option<String> = None;
let mut last_fetch: Option<SystemTime> = None;
let mut last_ok = false;
crate::events::spawn_poll_loop(
app,
"weather",
&REFRESH,
RETRY_INTERVAL,
Duration::ZERO,
move || {
let location = configured_location();
// Wall-clock, not Instant: Instant is CLOCK_MONOTONIC-backed and
// freezes across suspend, which would hide however long the
// machine was actually asleep and leave stale weather up long
// after a resume-triggered refresh should have gone through.
let due = match last_fetch {
None => true,
Some(t) => t.elapsed().map_or(true, |e| {
e >= if last_ok { FETCH_INTERVAL } else { RETRY_INTERVAL }
}),
};
if !due && last_location.as_deref() == Some(&location) {
return None;
}
let payload = fetch(&location);
last_ok = !payload.is_null();
last_fetch = Some(SystemTime::now());
last_location = Some(location);
// Failed fetches emit nothing: stale weather beats no weather,
// and the frontend tolerates the channel's absence.
last_ok.then_some(payload)
},
);
}
/// Bare 5-digit entries are US zip codes; unqualified they geocode
/// anywhere on earth (wttr.in resolves "10001" to Cáceres, Spain), so
/// pin them to the US.
fn normalize_location(location: &str) -> String {
let loc = location.trim();
if loc.len() == 5 && loc.bytes().all(|b| b.is_ascii_digit()) {
format!("{loc},USA")
} else {
loc.to_string()
}
}
fn wttr_url(location: &str) -> String {
format!(
"https://wttr.in/{}?format=j1",
normalize_location(location).replace(' ', "+")
)
}
fn fetch(location: &str) -> Value {
match crate::readers::fetch_url(&wttr_url(location), 15) {
Some(body) => parse_wttr(&body),
None => Value::Null,
}
}
static RESOLVE_CACHE: Mutex<BTreeMap<String, Value>> = Mutex::new(BTreeMap::new());
/// Resolves a location string to the area wttr.in will actually report
/// on — the settings window's live preview. Non-empty resolutions are
/// cached (places don't move, and wttr.in rate-limits); the empty
/// string means IP auto-detect, always fetched fresh since its answer
/// changes when the machine moves.
pub fn resolve(location: &str) -> Option<Value> {
let key = normalize_location(location);
if !key.is_empty() {
if let Some(hit) = RESOLVE_CACHE.lock().unwrap().get(&key) {
return Some(hit.clone());
}
}
let body = crate::readers::fetch_url(&wttr_url(location), 15)?;
let area = parse_area(&body)?;
if !key.is_empty() {
RESOLVE_CACHE.lock().unwrap().insert(key, area.clone());
}
Some(area)
}
/// Extracts `nearest_area` from a wttr.in `?format=j1` body.
fn parse_area(body: &[u8]) -> Option<Value> {
let data = serde_json::from_slice::<Value>(body).ok()?;
let a = &data["nearest_area"][0];
let text = |v: &Value| v[0]["value"].as_str().unwrap_or("").to_string();
let area = text(&a["areaName"]);
if area.is_empty() {
return None;
}
Some(json!({
"area": area,
"region": text(&a["region"]),
"country": text(&a["country"]),
"lat": a["latitude"].as_str().and_then(|s| s.parse::<f64>().ok()),
"lon": a["longitude"].as_str().and_then(|s| s.parse::<f64>().ok()),
}))
}
/// Extracts the fields the weather widget shows from a wttr.in
/// `?format=j1` response body.
fn parse_wttr(body: &[u8]) -> Value {
let Ok(data) = serde_json::from_slice::<Value>(body) else {
return Value::Null;
};
let current = &data["current_condition"][0];
let text = |v: &Value| v.as_str().unwrap_or("").to_string();
let num = |v: &Value| v.as_str().and_then(|s| s.parse::<f64>().ok());
let Some(temp_c) = num(¤t["temp_C"]) else {
return Value::Null;
};
let forecast: Vec<Value> = data["weather"]
.as_array()
.map(|days| days.iter().filter_map(parse_day).collect())
.unwrap_or_default();
json!({
"tempC": temp_c,
"tempF": num(¤t["temp_F"]),
"feelsC": num(¤t["FeelsLikeC"]),
"feelsF": num(¤t["FeelsLikeF"]),
"code": text(¤t["weatherCode"]).parse::<u32>().ok(),
"desc": text(¤t["weatherDesc"][0]["value"]),
"humidity": num(¤t["humidity"]),
"area": text(&data["nearest_area"][0]["areaName"][0]["value"]),
"forecast": forecast,
})
}
/// One day of the wttr.in 3-day forecast: hi/lo, the midday condition
/// (as the day's representative icon), and the day's peak rain chance.
fn parse_day(day: &Value) -> Option<Value> {
let num = |v: &Value| v.as_str().and_then(|s| s.parse::<f64>().ok());
let hourly = day["hourly"].as_array()?;
let midday = hourly.get(hourly.len() / 2)?;
let rain = hourly
.iter()
.filter_map(|h| num(&h["chanceofrain"]))
.fold(f64::NAN, f64::max);
Some(json!({
"date": day["date"].as_str()?,
"minC": num(&day["mintempC"])?,
"maxC": num(&day["maxtempC"])?,
"minF": num(&day["mintempF"]),
"maxF": num(&day["maxtempF"]),
"code": midday["weatherCode"].as_str().and_then(|s| s.parse::<u32>().ok()),
"desc": midday["weatherDesc"][0]["value"].as_str().unwrap_or(""),
"rain": if rain.is_nan() { None } else { Some(rain) },
"hourly": hourly.iter().filter_map(parse_hour).collect::<Vec<_>>(),
}))
}
/// One 3-hour forecast slot. wttr.in's `time` is minutes-as-text
/// ("0", "300", ... "2100"); emitted as the hour of day.
fn parse_hour(slot: &Value) -> Option<Value> {
let num = |v: &Value| v.as_str().and_then(|s| s.parse::<f64>().ok());
Some(json!({
"hour": slot["time"].as_str()?.parse::<u32>().ok()? / 100,
"tempC": num(&slot["tempC"])?,
"tempF": num(&slot["tempF"]),
"code": slot["weatherCode"].as_str().and_then(|s| s.parse::<u32>().ok()),
"rain": num(&slot["chanceofrain"]),
}))
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"current_condition": [{
"temp_C": "21", "temp_F": "70",
"FeelsLikeC": "20", "FeelsLikeF": "68",
"weatherCode": "113",
"weatherDesc": [{ "value": "Sunny" }],
"humidity": "40"
}],
"nearest_area": [{
"areaName": [{ "value": "Oakland" }],
"region": [{ "value": "California" }],
"country": [{ "value": "United States of America" }],
"latitude": "37.805", "longitude": "-122.273"
}],
"weather": [{
"date": "2026-08-01",
"mintempC": "14", "mintempF": "57",
"maxtempC": "24", "maxtempF": "75",
"hourly": [
{ "time": "0", "tempC": "15", "tempF": "59",
"weatherCode": "113", "chanceofrain": "10" },
{ "time": "1200", "tempC": "22", "tempF": "72",
"weatherCode": "176", "chanceofrain": "70",
"weatherDesc": [{ "value": "Patchy rain nearby" }] },
{ "time": "2100", "tempC": "16", "tempF": "61",
"weatherCode": "113", "chanceofrain": "20" }
]
}]
}"#;
#[test]
fn extracts_widget_fields() {
assert_eq!(
parse_wttr(SAMPLE.as_bytes()),
json!({
"tempC": 21.0,
"tempF": 70.0,
"feelsC": 20.0,
"feelsF": 68.0,
"code": 113,
"desc": "Sunny",
"humidity": 40.0,
"area": "Oakland",
"forecast": [{
"date": "2026-08-01",
"minC": 14.0,
"maxC": 24.0,
"minF": 57.0,
"maxF": 75.0,
"code": 176,
"desc": "Patchy rain nearby",
"rain": 70.0,
"hourly": [
{ "hour": 0, "tempC": 15.0, "tempF": 59.0,
"code": 113, "rain": 10.0 },
{ "hour": 12, "tempC": 22.0, "tempF": 72.0,
"code": 176, "rain": 70.0 },
{ "hour": 21, "tempC": 16.0, "tempF": 61.0,
"code": 113, "rain": 20.0 }
],
}],
})
);
}
#[test]
fn bare_us_zip_gets_country_suffix() {
assert_eq!(normalize_location(" 10001 "), "10001,USA");
assert_eq!(normalize_location("10001,USA"), "10001,USA");
assert_eq!(normalize_location("New York"), "New York");
assert_eq!(normalize_location("94107x"), "94107x");
assert_eq!(normalize_location(""), "");
}
#[test]
fn url_encodes_spaces_and_normalizes() {
assert_eq!(wttr_url("New York"), "https://wttr.in/New+York?format=j1");
assert_eq!(wttr_url("94107"), "https://wttr.in/94107,USA?format=j1");
}
#[test]
fn parses_nearest_area() {
assert_eq!(
parse_area(SAMPLE.as_bytes()),
Some(json!({
"area": "Oakland",
"region": "California",
"country": "United States of America",
"lat": 37.805,
"lon": -122.273,
}))
);
assert_eq!(parse_area(b"<html>rate limited</html>"), None);
assert_eq!(parse_area(br#"{ "nearest_area": [] }"#), None);
}
#[test]
fn day_without_temperatures_is_skipped() {
let body = br#"{
"current_condition": [{ "temp_C": "21" }],
"weather": [{ "date": "2026-08-01", "hourly": [{}] }]
}"#;
assert_eq!(parse_wttr(body)["forecast"], json!([]));
}
#[test]
fn invalid_json_is_null() {
assert_eq!(parse_wttr(b"<html>rate limited</html>"), Value::Null);
}
#[test]
fn missing_temperature_is_null() {
assert_eq!(parse_wttr(br#"{ "current_condition": [{}] }"#), Value::Null);
}
}
|