added more data to van weatere and moved retry logic to tenacity

This commit is contained in:
2026-03-15 11:47:27 -04:00
parent 7611a3b2df
commit c83bbe2c24
2 changed files with 71 additions and 23 deletions

View File

@@ -1,13 +1,13 @@
"""Van weather service - fetches weather with masked GPS for privacy.""" """Van weather service - fetches weather with masked GPS for privacy."""
import logging import logging
import time
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Any from typing import Annotated, Any
import requests import requests
import typer import typer
from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.schedulers.blocking import BlockingScheduler
from tenacity import before_sleep_log, retry, stop_after_attempt, wait_fixed
from python.common import configure_logger from python.common import configure_logger
from python.van_weather.models import Config, DailyForecast, HourlyForecast, Weather from python.van_weather.models import Config, DailyForecast, HourlyForecast, Weather
@@ -29,15 +29,25 @@ CONDITION_MAP = {
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@retry(
stop=stop_after_attempt(3),
wait=wait_fixed(5),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def get_ha_state(url: str, token: str, entity_id: str) -> float: def get_ha_state(url: str, token: str, entity_id: str) -> float:
"""Get numeric state from Home Assistant entity.""" """Get numeric state from Home Asasistant entity."""
response = requests.get( response = requests.get(
f"{url}/api/states/{entity_id}", f"{url}/api/states/{entity_id}",
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}"},
timeout=30, timeout=30,
) )
response.raise_for_status() response.raise_for_status()
return float(response.json()["state"]) state = response.json()["state"]
if state in ("unavailable", "unknown"):
error = f"{entity_id} is {state}"
raise ValueError(error)
return float(state)
def parse_daily_forecast(data: dict[str, dict[str, Any]]) -> list[DailyForecast]: def parse_daily_forecast(data: dict[str, dict[str, Any]]) -> list[DailyForecast]:
@@ -55,6 +65,9 @@ def parse_daily_forecast(data: dict[str, dict[str, Any]]) -> list[DailyForecast]
temperature=day.get("temperatureHigh"), temperature=day.get("temperatureHigh"),
templow=day.get("temperatureLow"), templow=day.get("temperatureLow"),
precipitation_probability=day.get("precipProbability"), precipitation_probability=day.get("precipProbability"),
moon_phase=day.get("moonPhase"),
wind_gust=day.get("windGust"),
cloud_cover=day.get("cloudCover"),
) )
) )
@@ -80,6 +93,12 @@ def parse_hourly_forecast(data: dict[str, dict[str, Any]]) -> list[HourlyForecas
return hourly_forecasts return hourly_forecasts
@retry(
stop=stop_after_attempt(3),
wait=wait_fixed(5),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def fetch_weather(api_key: str, lat: float, lon: float) -> Weather: def fetch_weather(api_key: str, lat: float, lon: float) -> Weather:
"""Fetch weather from Pirate Weather API.""" """Fetch weather from Pirate Weather API."""
url = f"https://api.pirateweather.net/forecast/{api_key}/{lat},{lon}" url = f"https://api.pirateweather.net/forecast/{api_key}/{lat},{lon}"
@@ -102,29 +121,25 @@ def fetch_weather(api_key: str, lat: float, lon: float) -> Weather:
summary=current.get("summary"), summary=current.get("summary"),
pressure=current.get("pressure"), pressure=current.get("pressure"),
visibility=current.get("visibility"), visibility=current.get("visibility"),
uv_index=current.get("uvIndex"),
ozone=current.get("ozone"),
nearest_storm_distance=current.get("nearestStormDistance"),
nearest_storm_bearing=current.get("nearestStormBearing"),
precip_probability=current.get("precipProbability"),
cloud_cover=current.get("cloudCover"),
daily_forecasts=daily_forecasts, daily_forecasts=daily_forecasts,
hourly_forecasts=hourly_forecasts, hourly_forecasts=hourly_forecasts,
) )
@retry(
stop=stop_after_attempt(3),
wait=wait_fixed(5),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def post_to_ha(url: str, token: str, weather: Weather) -> None: def post_to_ha(url: str, token: str, weather: Weather) -> None:
"""Post weather data to Home Assistant as sensor entities.""" """Post weather data to Home Assistant as sensor entities."""
max_retries = 6
retry_delay = 10
for attempt in range(1, max_retries + 1):
try:
_post_weather_data(url, token, weather)
except requests.RequestException:
if attempt == max_retries:
logger.exception(f"Failed to post weather to HA after {max_retries} attempts")
return
logger.warning(f"Post to HA failed (attempt {attempt}/{max_retries}), retrying in {retry_delay}s")
time.sleep(retry_delay)
def _post_weather_data(url: str, token: str, weather: Weather) -> None:
"""Post all weather data to Home Assistant. Raises on failure."""
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
# Post current weather as individual sensors # Post current weather as individual sensors
@@ -161,6 +176,30 @@ def _post_weather_data(url: str, token: str, weather: Weather) -> None:
"state": weather.visibility, "state": weather.visibility,
"attributes": {"unit_of_measurement": "mi"}, "attributes": {"unit_of_measurement": "mi"},
}, },
"sensor.van_weather_uv_index": {
"state": weather.uv_index,
"attributes": {"friendly_name": "Van Weather UV Index", "icon": "mdi:sun-wireless"},
},
"sensor.van_weather_ozone": {
"state": weather.ozone,
"attributes": {"unit_of_measurement": "DU", "icon": "mdi:earth"},
},
"sensor.van_weather_nearest_storm_distance": {
"state": weather.nearest_storm_distance,
"attributes": {"unit_of_measurement": "mi", "icon": "mdi:weather-lightning"},
},
"sensor.van_weather_nearest_storm_bearing": {
"state": weather.nearest_storm_bearing,
"attributes": {"unit_of_measurement": "°", "icon": "mdi:weather-lightning"},
},
"sensor.van_weather_precip_probability": {
"state": int((weather.precip_probability or 0) * 100),
"attributes": {"unit_of_measurement": "%", "icon": "mdi:weather-rainy"},
},
"sensor.van_weather_cloud_cover": {
"state": int((weather.cloud_cover or 0) * 100),
"attributes": {"unit_of_measurement": "%", "icon": "mdi:weather-cloudy"},
},
} }
for entity_id, data in sensors.items(): for entity_id, data in sensors.items():
@@ -209,7 +248,7 @@ def _post_weather_data(url: str, token: str, weather: Weather) -> None:
def update_weather(config: Config) -> None: def update_weather(config: Config) -> None:
"""Fetch GPS, mask it, get weather, post to HA.""" """Fetch weather using last-known location, post to HA."""
lat = get_ha_state(config.ha_url, config.ha_token, config.lat_entity) lat = get_ha_state(config.ha_url, config.ha_token, config.lat_entity)
lon = get_ha_state(config.ha_url, config.ha_token, config.lon_entity) lon = get_ha_state(config.ha_url, config.ha_token, config.lon_entity)
@@ -218,7 +257,7 @@ def update_weather(config: Config) -> None:
logger.info(f"Masked location: {masked_lat}, {masked_lon}") logger.info(f"Masked location: {masked_lat}, {masked_lon}")
weather = fetch_weather(config.pirate_weather_api_key, masked_lat, masked_lon) weather = fetch_weather(config.pirate_weather_api_key, lat, lon)
logger.info(f"Weather: {weather.temperature}°F, {weather.condition}") logger.info(f"Weather: {weather.temperature}°F, {weather.condition}")
post_to_ha(config.ha_url, config.ha_token, weather) post_to_ha(config.ha_url, config.ha_token, weather)

View File

@@ -11,8 +11,8 @@ class Config(BaseModel):
ha_url: str ha_url: str
ha_token: str ha_token: str
pirate_weather_api_key: str pirate_weather_api_key: str
lat_entity: str = "sensor.gps_latitude" lat_entity: str = "sensor.van_last_known_latitude"
lon_entity: str = "sensor.gps_longitude" lon_entity: str = "sensor.van_last_known_longitude"
mask_decimals: int = 1 # ~11km accuracy mask_decimals: int = 1 # ~11km accuracy
@@ -24,6 +24,9 @@ class DailyForecast(BaseModel):
temperature: float | None = None # High temperature: float | None = None # High
templow: float | None = None # Low templow: float | None = None # Low
precipitation_probability: float | None = None precipitation_probability: float | None = None
moon_phase: float | None = None
wind_gust: float | None = None
cloud_cover: float | None = None
@field_serializer("date_time") @field_serializer("date_time")
def serialize_date_time(self, date_time: datetime) -> str: def serialize_date_time(self, date_time: datetime) -> str:
@@ -57,5 +60,11 @@ class Weather(BaseModel):
summary: str | None = None summary: str | None = None
pressure: float | None = None pressure: float | None = None
visibility: float | None = None visibility: float | None = None
uv_index: float | None = None
ozone: float | None = None
nearest_storm_distance: float | None = None
nearest_storm_bearing: float | None = None
precip_probability: float | None = None
cloud_cover: float | None = None
daily_forecasts: list[DailyForecast] = [] daily_forecasts: list[DailyForecast] = []
hourly_forecasts: list[HourlyForecast] = [] hourly_forecasts: list[HourlyForecast] = []