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
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:
Generated
+1686
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
members = ["rust/*"]
|
||||||
Generated
+1685
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
pkgs,
|
pkgs,
|
||||||
inputs,
|
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
|
let
|
||||||
|
van-weather = pkgs.callPackage ../../../rust/van_weather/package.nix { };
|
||||||
|
in
|
||||||
{
|
{
|
||||||
systemd.services.van-weather = {
|
systemd.services.van-weather = {
|
||||||
description = "Van Weather Service";
|
description = "Van Weather Service";
|
||||||
@@ -13,13 +15,9 @@
|
|||||||
requires = [ "home-assistant.service" ];
|
requires = [ "home-assistant.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
environment = {
|
|
||||||
PYTHONPATH = "${inputs.self}/";
|
|
||||||
};
|
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "simple";
|
Type = "simple";
|
||||||
ExecStart = "${pkgs.my_python}/bin/python -m python.van_weather.main";
|
ExecStart = "${van-weather}/bin/van-weather";
|
||||||
EnvironmentFile = "/etc/van_weather.env";
|
EnvironmentFile = "/etc/van_weather.env";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
@@ -29,7 +27,6 @@
|
|||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
ProtectHome = "read-only";
|
ProtectHome = "read-only";
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
ReadOnlyPaths = [ "${inputs.self}" ];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user