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,
|
||||
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}" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user