feat(van-weather): port weather service to Rust
treefmt / nix fmt (pull_request) Successful in 6s
pytest / pytest (pull_request) Successful in 33s
test ebook search / test-ebook-search (pull_request) Successful in 38s
build_systems / build-bob (pull_request) Successful in 57s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m6s
build_systems / build-brain (pull_request) Successful in 1m47s
build_systems / build-jeeves (pull_request) Successful in 2m27s

Add a Rust implementation with privacy-masked locations, HTTP retries,
and Home Assistant sensor publishing. Package it with Nix and switch the
systemd service from Python to the compiled binary.
This commit is contained in:
2026-08-18 22:39:52 -04:00
committed by Richie
parent e5132e2a0b
commit 507b23f6ee
7 changed files with 3978 additions and 7 deletions
Generated
+1686
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
[workspace]
resolver = "2"
members = ["rust/*"]
+1685
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "van-weather"
version = "0.1.0"
edition = "2024"
description = "Fetch privacy-masked weather for a van and publish it to Home Assistant"
license = "MIT"
[dependencies]
anyhow = "1.0"
chrono = "0.4"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
log = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints.clippy]
all = "deny"
pedantic = "deny"
+13
View File
@@ -0,0 +1,13 @@
{ rustPlatform }:
rustPlatform.buildRustPackage {
pname = "van-weather";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
meta = {
description = "Privacy-masked van weather publisher for Home Assistant";
mainProgram = "van-weather";
};
}
+565
View File
@@ -0,0 +1,565 @@
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<DailyApiForecast>,
#[serde(default)]
hourly: ForecastBlock<HourlyApiForecast>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CurrentWeather {
temperature: Option<f64>,
apparent_temperature: Option<f64>,
humidity: Option<f64>,
wind_speed: Option<f64>,
wind_bearing: Option<f64>,
icon: Option<String>,
pressure: Option<f64>,
visibility: Option<f64>,
uv_index: Option<f64>,
ozone: Option<f64>,
nearest_storm_distance: Option<f64>,
nearest_storm_bearing: Option<f64>,
precip_probability: Option<f64>,
cloud_cover: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct ForecastBlock<T> {
#[serde(default)]
data: Vec<T>,
}
impl<T> Default for ForecastBlock<T> {
fn default() -> Self {
Self { data: Vec::new() }
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DailyApiForecast {
time: Option<i64>,
icon: Option<String>,
temperature_high: Option<f64>,
temperature_low: Option<f64>,
precip_probability: Option<f64>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HourlyApiForecast {
time: Option<i64>,
icon: Option<String>,
temperature: Option<f64>,
precip_probability: Option<f64>,
}
#[derive(Debug)]
struct Weather {
current: CurrentWeather,
daily: Vec<DailyForecast>,
hourly: Vec<HourlyForecast>,
}
#[derive(Debug)]
struct DailyForecast {
datetime: DateTime<Utc>,
condition: &'static str,
temperature: Option<f64>,
templow: Option<f64>,
precipitation_probability: Option<f64>,
}
#[derive(Debug)]
struct HourlyForecast {
datetime: DateTime<Utc>,
condition: &'static str,
temperature: Option<f64>,
precipitation_probability: Option<f64>,
}
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<Client> {
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<f64> {
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::<f64>()
.with_context(|| format!("{entity_id} state is not numeric: {state}"))
}
fn fetch_weather(client: &Client, api_key: &str, lat: f64, lon: f64) -> Result<Weather> {
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::<ApiResponse>()
.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<i64>) -> Option<DateTime<Utc>> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
updates.push((
"sensor.van_weather_forecast_hourly".to_owned(),
json!({"state": hourly.len(), "attributes": {"forecast": hourly}}),
));
updates
}
fn sensor(entity_id: &str, state: Option<Value>, 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<f64>) -> 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);
}
}
+4 -7
View File
@@ -1,8 +1,10 @@
{
pkgs,
inputs,
...
}:
let
van-weather = pkgs.callPackage ../../../rust/van_weather/package.nix { };
in
{
systemd.services.van-weather = {
description = "Van Weather Service";
@@ -13,13 +15,9 @@
requires = [ "home-assistant.service" ];
wantedBy = [ "multi-user.target" ];
environment = {
PYTHONPATH = "${inputs.self}/";
};
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.my_python}/bin/python -m python.van_weather.main";
ExecStart = "${van-weather}/bin/van-weather";
EnvironmentFile = "/etc/van_weather.env";
Restart = "on-failure";
RestartSec = "5s";
@@ -29,7 +27,6 @@
ProtectSystem = "strict";
ProtectHome = "read-only";
PrivateTmp = true;
ReadOnlyPaths = [ "${inputs.self}" ];
};
};
}