use std::{thread, time::Duration}; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; use clap::Parser; use log::{error, info}; use reqwest::{ StatusCode, Url, blocking::{Client, ClientBuilder}, header::{AUTHORIZATION, HeaderMap, HeaderValue}, retry, }; use serde::Deserialize; use serde_json::{Value, json}; const LAT_ENTITY: &str = "sensor.van_last_known_latitude"; const LON_ENTITY: &str = "sensor.van_last_known_longitude"; const PIRATE_WEATHER_HOST: &str = "api.pirateweather.net"; const MASK_DECIMALS: u32 = 1; const MASK_FACTOR: f64 = decimal_factor(MASK_DECIMALS); const RETRIES_PER_REQUEST: u32 = 2; const fn decimal_factor(decimals: u32) -> f64 { let mut factor = 1.0; let mut remaining = decimals; while remaining > 0 { factor *= 10.0; remaining -= 1; } factor } #[derive(Debug, Parser)] #[command(about, version)] struct Args { #[arg(long, env = "HA_URL")] ha_url: String, #[arg(long, env = "HA_TOKEN", hide_env_values = true)] ha_token: String, #[arg(long, env = "PIRATE_WEATHER_API_KEY", hide_env_values = true)] pirate_weather_api_key: String, #[arg( long, default_value_t = 900, value_parser = clap::value_parser!(u64).range(1..) )] interval: u64, #[arg(long, default_value = "info", env = "RUST_LOG")] log_level: String, } #[derive(Debug, Deserialize)] struct HaState { state: String, } #[derive(Debug)] struct HttpClients { home_assistant: Client, pirate_weather: Client, } #[derive(Debug, Default, Deserialize)] struct ApiResponse { #[serde(default)] currently: CurrentWeather, #[serde(default)] daily: ForecastBlock, #[serde(default)] hourly: ForecastBlock, } #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct CurrentWeather { temperature: Option, apparent_temperature: Option, humidity: Option, wind_speed: Option, wind_bearing: Option, icon: Option, pressure: Option, visibility: Option, uv_index: Option, ozone: Option, nearest_storm_distance: Option, nearest_storm_bearing: Option, precip_probability: Option, cloud_cover: Option, } #[derive(Debug, Deserialize)] struct ForecastBlock { #[serde(default)] data: Vec, } impl Default for ForecastBlock { fn default() -> Self { Self { data: Vec::new() } } } #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct DailyApiForecast { time: Option, icon: Option, temperature_high: Option, temperature_low: Option, precip_probability: Option, } #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct HourlyApiForecast { time: Option, icon: Option, temperature: Option, precip_probability: Option, } #[derive(Debug)] struct Weather { current: CurrentWeather, daily: Vec, hourly: Vec, } #[derive(Debug)] struct DailyForecast { datetime: DateTime, condition: &'static str, temperature: Option, templow: Option, precipitation_probability: Option, } #[derive(Debug)] struct HourlyForecast { datetime: DateTime, condition: &'static str, temperature: Option, precipitation_probability: Option, } fn main() -> Result<()> { let Args { ha_url, ha_token, pirate_weather_api_key, interval, log_level, } = Args::parse(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level.as_str())) .init(); let ha_url = ha_url.trim_end_matches('/').to_owned(); let ha_host = Url::parse(&ha_url) .context("HA_URL is not a valid URL")? .host_str() .context("HA_URL has no host")? .to_owned(); let clients = HttpClients { home_assistant: build_client(&ha_host, Some(&ha_token))?, pirate_weather: build_client(PIRATE_WEATHER_HOST, None)?, }; info!("Starting van weather service, polling every {interval}s"); loop { if let Err(err) = update_weather(&clients, &ha_url, &pirate_weather_api_key) { error!("Weather update failed: {err:#}"); } thread::sleep(Duration::from_secs(interval)); } } fn build_client(host: &str, bearer_token: Option<&str>) -> Result { let policy = retry::for_host(host.to_owned()) .max_retries_per_request(RETRIES_PER_REQUEST) .classify_fn(|request| { let retryable = request.error().is_some() || request.status().is_some_and(|status| { status == StatusCode::REQUEST_TIMEOUT || status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() }); if retryable { request.retryable() } else { request.success() } }); let mut builder = ClientBuilder::new() .timeout(Duration::from_secs(30)) .retry(policy); if let Some(token) = bearer_token { let mut authorization = HeaderValue::from_str(&format!("Bearer {token}")) .context("HA_TOKEN contains invalid header characters")?; authorization.set_sensitive(true); let mut headers = HeaderMap::new(); headers.insert(AUTHORIZATION, authorization); builder = builder.default_headers(headers); } builder .build() .with_context(|| format!("failed to create HTTP client for {host}")) } fn update_weather(clients: &HttpClients, ha_url: &str, api_key: &str) -> Result<()> { let lat = get_ha_state(&clients.home_assistant, ha_url, LAT_ENTITY)?; let lon = get_ha_state(&clients.home_assistant, ha_url, LON_ENTITY)?; let masked_lat = mask_coordinate(lat); let masked_lon = mask_coordinate(lon); info!("Masked location: {masked_lat}, {masked_lon}"); let weather = fetch_weather(&clients.pirate_weather, api_key, masked_lat, masked_lon)?; info!( "Weather: {}°F, {}", weather .current .temperature .map_or_else(|| "unknown".to_owned(), |value| value.to_string()), condition(weather.current.icon.as_deref()) ); post_to_ha(&clients.home_assistant, ha_url, &weather)?; info!("Posted weather to Home Assistant"); Ok(()) } fn mask_coordinate(value: f64) -> f64 { (value * MASK_FACTOR).round() / MASK_FACTOR } fn get_ha_state(client: &Client, ha_url: &str, entity_id: &str) -> Result { let HaState { state } = client .get(format!("{ha_url}/api/states/{entity_id}")) .send() .with_context(|| format!("request for {entity_id} failed"))? .error_for_status() .with_context(|| format!("Home Assistant rejected {entity_id} request"))? .json() .context("Home Assistant returned invalid JSON")?; if matches!(state.as_str(), "unavailable" | "unknown") { bail!("{entity_id} is {state}"); } state .parse::() .with_context(|| format!("{entity_id} state is not numeric: {state}")) } fn fetch_weather(client: &Client, api_key: &str, lat: f64, lon: f64) -> Result { let response = client .get(format!( "https://{PIRATE_WEATHER_HOST}/forecast/{api_key}/{lat},{lon}" )) .query(&[("units", "us")]) .send() .context("Pirate Weather request failed")? .error_for_status() .context("Pirate Weather rejected request")?; let data = response .json::() .context("Pirate Weather returned invalid JSON")?; Ok(parse_weather(data)) } fn parse_weather(data: ApiResponse) -> Weather { let daily = data .daily .data .into_iter() .take(8) .filter_map(|day| { timestamp(day.time).map(|datetime| DailyForecast { datetime, condition: condition(day.icon.as_deref()), temperature: day.temperature_high, templow: day.temperature_low, precipitation_probability: day.precip_probability, }) }) .collect(); let hourly = data .hourly .data .into_iter() .take(48) .filter_map(|hour| { timestamp(hour.time).map(|datetime| HourlyForecast { datetime, condition: condition(hour.icon.as_deref()), temperature: hour.temperature, precipitation_probability: hour.precip_probability, }) }) .collect(); Weather { current: data.currently, daily, hourly, } } fn timestamp(value: Option) -> Option> { value .filter(|value| *value != 0) .and_then(DateTime::from_timestamp_secs) } fn condition(icon: Option<&str>) -> &'static str { match icon.unwrap_or_default() { "clear-day" => "sunny", "clear-night" => "clear-night", "rain" => "rainy", "snow" => "snowy", "sleet" => "snowy-rainy", "wind" => "windy", "fog" => "fog", "partly-cloudy-day" | "partly-cloudy-night" => "partlycloudy", _ => "cloudy", } } fn post_to_ha(client: &Client, ha_url: &str, weather: &Weather) -> Result<()> { for (entity_id, payload) in weather_updates(weather) { let response = client .post(format!("{ha_url}/api/states/{entity_id}")) .json(&payload) .send() .with_context(|| format!("failed to post {entity_id}"))?; ensure_success(response.status(), &entity_id)?; } Ok(()) } fn ensure_success(status: StatusCode, entity_id: &str) -> Result<()> { if status.is_success() { Ok(()) } else { bail!("Home Assistant rejected {entity_id} update with {status}") } } #[allow(clippy::too_many_lines)] fn weather_updates(weather: &Weather) -> Vec<(String, Value)> { let current = &weather.current; let mut updates = vec![ sensor( "sensor.van_weather_condition", Some(json!(condition(current.icon.as_deref()))), json!({"friendly_name": "Van Weather Condition"}), ), sensor( "sensor.van_weather_temperature", current.temperature.map(|value| json!(value)), json!({"unit_of_measurement": "°F", "device_class": "temperature"}), ), sensor( "sensor.van_weather_apparent_temperature", current.apparent_temperature.map(|value| json!(value)), json!({"unit_of_measurement": "°F", "device_class": "temperature"}), ), sensor( "sensor.van_weather_humidity", Some(json!(percent(current.humidity))), json!({"unit_of_measurement": "%", "device_class": "humidity"}), ), sensor( "sensor.van_weather_pressure", current.pressure.map(|value| json!(value)), json!({"unit_of_measurement": "mbar", "device_class": "pressure"}), ), sensor( "sensor.van_weather_wind_speed", current.wind_speed.map(|value| json!(value)), json!({"unit_of_measurement": "mph", "device_class": "wind_speed"}), ), sensor( "sensor.van_weather_wind_bearing", current.wind_bearing.map(|value| json!(value)), json!({"unit_of_measurement": "°"}), ), sensor( "sensor.van_weather_visibility", current.visibility.map(|value| json!(value)), json!({"unit_of_measurement": "mi"}), ), sensor( "sensor.van_weather_uv_index", current.uv_index.map(|value| json!(value)), json!({"friendly_name": "Van Weather UV Index", "icon": "mdi:sun-wireless"}), ), sensor( "sensor.van_weather_ozone", current.ozone.map(|value| json!(value)), json!({"unit_of_measurement": "DU", "icon": "mdi:earth"}), ), sensor( "sensor.van_weather_nearest_storm_distance", current.nearest_storm_distance.map(|value| json!(value)), json!({"unit_of_measurement": "mi", "icon": "mdi:weather-lightning"}), ), sensor( "sensor.van_weather_nearest_storm_bearing", current.nearest_storm_bearing.map(|value| json!(value)), json!({"unit_of_measurement": "°", "icon": "mdi:weather-lightning"}), ), sensor( "sensor.van_weather_precip_probability", Some(json!(percent(current.precip_probability))), json!({"unit_of_measurement": "%", "icon": "mdi:weather-rainy"}), ), sensor( "sensor.van_weather_cloud_cover", Some(json!(percent(current.cloud_cover))), json!({"unit_of_measurement": "%", "icon": "mdi:weather-cloudy"}), ), ] .into_iter() .flatten() .collect::>(); let daily = weather .daily .iter() .map(|forecast| { json!({ "datetime": forecast.datetime.to_rfc3339(), "condition": forecast.condition, "temperature": forecast.temperature, "templow": forecast.templow, "precipitation_probability": percent(forecast.precipitation_probability), }) }) .collect::>(); updates.push(( "sensor.van_weather_forecast_daily".to_owned(), json!({"state": daily.len(), "attributes": {"forecast": daily}}), )); let hourly = weather .hourly .iter() .map(|forecast| { json!({ "datetime": forecast.datetime.to_rfc3339(), "condition": forecast.condition, "temperature": forecast.temperature, "precipitation_probability": percent(forecast.precipitation_probability), }) }) .collect::>(); updates.push(( "sensor.van_weather_forecast_hourly".to_owned(), json!({"state": hourly.len(), "attributes": {"forecast": hourly}}), )); updates } fn sensor(entity_id: &str, state: Option, attributes: Value) -> Option<(String, Value)> { state.map(|state| { let mut payload = serde_json::Map::new(); payload.insert("state".to_owned(), state); payload.insert("attributes".to_owned(), attributes); (entity_id.to_owned(), Value::Object(payload)) }) } #[allow(clippy::cast_possible_truncation)] fn percent(value: Option) -> i64 { // Preserve Python's int(probability * 100) behavior for Home Assistant. (value.unwrap_or_default() * 100.0) as i64 } #[cfg(test)] mod tests { use super::*; #[test] fn maps_conditions_for_home_assistant() { assert_eq!(condition(Some("clear-day")), "sunny"); assert_eq!(condition(Some("sleet")), "snowy-rainy"); assert_eq!(condition(Some("partly-cloudy-night")), "partlycloudy"); assert_eq!(condition(Some("unexpected")), "cloudy"); assert_eq!(condition(None), "cloudy"); } #[test] fn parses_api_response_and_builds_compatible_payloads() { let response: ApiResponse = serde_json::from_value(json!({ "currently": { "temperature": 72.5, "humidity": 0.67, "icon": "clear-day", "precipProbability": 0.129, "cloudCover": 0.4, "summary": "Fine" }, "daily": {"data": [{ "time": 1_700_000_000, "icon": "rain", "temperatureHigh": 75.0, "temperatureLow": 52.0, "precipProbability": 0.8 }]}, "hourly": {"data": [{ "time": 1_700_000_000, "icon": "fog", "temperature": 61.0, "precipProbability": 0.05 }]} })) .unwrap(); let weather = parse_weather(response); let updates = weather_updates(&weather); let find = |id: &str| updates.iter().find(|(entity, _)| entity == id).unwrap(); assert_eq!(find("sensor.van_weather_condition").1["state"], "sunny"); assert_eq!(find("sensor.van_weather_humidity").1["state"], 67); assert_eq!(find("sensor.van_weather_precip_probability").1["state"], 12); assert_eq!(find("sensor.van_weather_forecast_daily").1["state"], 1); assert_eq!( find("sensor.van_weather_forecast_daily").1["attributes"]["forecast"][0]["condition"], "rainy" ); assert_eq!(find("sensor.van_weather_forecast_hourly").1["state"], 1); } #[test] fn omits_missing_optional_current_sensors_but_keeps_percentage_sensors() { let weather = parse_weather(ApiResponse::default()); let updates = weather_updates(&weather); assert!( !updates .iter() .any(|(id, _)| id == "sensor.van_weather_temperature") ); assert_eq!( updates .iter() .find(|(id, _)| id == "sensor.van_weather_humidity") .unwrap() .1["state"], 0 ); } #[test] fn masks_coordinates_to_about_eleven_kilometres() { assert!((mask_coordinate(37.7749) - 37.8).abs() < f64::EPSILON); assert!((mask_coordinate(-122.4194) - (-122.4)).abs() < f64::EPSILON); } }