mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 13:08:19 -04:00
added more data to van weatere and moved retry logic to tenacity
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user