diff --git a/.vscode/settings.json b/.vscode/settings.json index d62b45b..3a08fd6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,7 +10,6 @@ "aiounifi", "alsa", "apiclient", - "apscheduler", "archlinux", "ashift", "asrouter", diff --git a/overlays/default.nix b/overlays/default.nix index 867e26b..6110f8e 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -20,7 +20,6 @@ ps: with ps; [ alembic apprise - apscheduler fastapi fastapi-cli httpx diff --git a/pyproject.toml b/pyproject.toml index 5e05930..452aed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ license = "MIT" dependencies = [ "alembic", "apprise", - "apscheduler", "beautifulsoup4", "bm25s", "ebooklib", @@ -20,7 +19,6 @@ dependencies = [ "httpx", "jinja2", "pgvector", - "polars", "psycopg[binary]", "pydantic", "pydantic-settings", diff --git a/python/van_weather/__init__.py b/python/van_weather/__init__.py deleted file mode 100644 index c51f75f..0000000 --- a/python/van_weather/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Van weather service - fetches weather with masked GPS location.""" diff --git a/python/van_weather/main.py b/python/van_weather/main.py deleted file mode 100644 index b15ea34..0000000 --- a/python/van_weather/main.py +++ /dev/null @@ -1,293 +0,0 @@ -"""Van weather service - fetches weather with masked GPS for privacy.""" - -import logging -from datetime import UTC, datetime -from typing import Annotated, Any - -import httpx -import typer -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.van_weather.models import Config, DailyForecast, HourlyForecast, Weather - -# Map Pirate Weather icons to Home Assistant conditions -CONDITION_MAP = { - "clear-day": "sunny", - "clear-night": "clear-night", - "rain": "rainy", - "snow": "snowy", - "sleet": "snowy-rainy", - "wind": "windy", - "fog": "fog", - "cloudy": "cloudy", - "partly-cloudy-day": "partlycloudy", - "partly-cloudy-night": "partlycloudy", -} - -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: - """Get numeric state from Home Asasistant entity.""" - response = httpx.get( - f"{url}/api/states/{entity_id}", - headers={"Authorization": f"Bearer {token}"}, - timeout=30, - ) - response.raise_for_status() - 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]: - """Parse daily forecast from Pirate Weather API.""" - daily = data.get("daily", {}).get("data", []) - daily_forecasts = [] - for day in daily[:8]: # Up to 8 days - time_stamp = day.get("time") - if time_stamp: - date_time = datetime.fromtimestamp(time_stamp, tz=UTC).isoformat() - daily_forecasts.append( - DailyForecast( - date_time=date_time, - condition=CONDITION_MAP.get(day.get("icon", ""), "cloudy"), - temperature=day.get("temperatureHigh"), - templow=day.get("temperatureLow"), - precipitation_probability=day.get("precipProbability"), - moon_phase=day.get("moonPhase"), - wind_gust=day.get("windGust"), - cloud_cover=day.get("cloudCover"), - ) - ) - - return daily_forecasts - - -def parse_hourly_forecast(data: dict[str, dict[str, Any]]) -> list[HourlyForecast]: - """Parse hourly forecast from Pirate Weather API.""" - hourly = data.get("hourly", {}).get("data", []) - hourly_forecasts = [] - for hour in hourly[:48]: # Up to 48 hours - time_stamp = hour.get("time") - if time_stamp: - date_time = datetime.fromtimestamp(time_stamp, tz=UTC).isoformat() - hourly_forecasts.append( - HourlyForecast( - date_time=date_time, - condition=CONDITION_MAP.get(hour.get("icon", ""), "cloudy"), - temperature=hour.get("temperature"), - precipitation_probability=hour.get("precipProbability"), - ) - ) - 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: - """Fetch weather from Pirate Weather API.""" - url = f"https://api.pirateweather.net/forecast/{api_key}/{lat},{lon}" - response = httpx.get(url, params={"units": "us"}, timeout=30) - response.raise_for_status() - data = response.json() - - daily_forecasts = parse_daily_forecast(data) - hourly_forecasts = parse_hourly_forecast(data) - - current = data.get("currently", {}) - icon = current.get("icon", "") - return Weather( - temperature=current.get("temperature"), - feels_like=current.get("apparentTemperature"), - humidity=current.get("humidity"), - wind_speed=current.get("windSpeed"), - wind_bearing=current.get("windBearing"), - condition=CONDITION_MAP.get(icon, "cloudy"), - summary=current.get("summary"), - pressure=current.get("pressure"), - 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, - 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: - """Post weather data to Home Assistant as sensor entities.""" - headers = {"Authorization": f"Bearer {token}"} - - # Post current weather as individual sensors - sensors = { - "sensor.van_weather_condition": { - "state": weather.condition or "unknown", - "attributes": {"friendly_name": "Van Weather Condition"}, - }, - "sensor.van_weather_temperature": { - "state": weather.temperature, - "attributes": {"unit_of_measurement": "°F", "device_class": "temperature"}, - }, - "sensor.van_weather_apparent_temperature": { - "state": weather.feels_like, - "attributes": {"unit_of_measurement": "°F", "device_class": "temperature"}, - }, - "sensor.van_weather_humidity": { - "state": int((weather.humidity or 0) * 100), - "attributes": {"unit_of_measurement": "%", "device_class": "humidity"}, - }, - "sensor.van_weather_pressure": { - "state": weather.pressure, - "attributes": {"unit_of_measurement": "mbar", "device_class": "pressure"}, - }, - "sensor.van_weather_wind_speed": { - "state": weather.wind_speed, - "attributes": {"unit_of_measurement": "mph", "device_class": "wind_speed"}, - }, - "sensor.van_weather_wind_bearing": { - "state": weather.wind_bearing, - "attributes": {"unit_of_measurement": "°"}, - }, - "sensor.van_weather_visibility": { - "state": weather.visibility, - "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(): - if data["state"] is not None: - response = httpx.post(f"{url}/api/states/{entity_id}", headers=headers, json=data, timeout=30) - response.raise_for_status() - - # Post daily forecast as JSON attribute sensor - daily_forecast = [ - { - "datetime": daily_forecast.date_time.isoformat(), - "condition": daily_forecast.condition, - "temperature": daily_forecast.temperature, - "templow": daily_forecast.templow, - "precipitation_probability": int((daily_forecast.precipitation_probability or 0) * 100), - } - for daily_forecast in weather.daily_forecasts - ] - - response = httpx.post( - f"{url}/api/states/sensor.van_weather_forecast_daily", - headers=headers, - json={"state": len(daily_forecast), "attributes": {"forecast": daily_forecast}}, - timeout=30, - ) - response.raise_for_status() - - # Post hourly forecast as JSON attribute sensor - hourly_forecast = [ - { - "datetime": hourly_forecast.date_time.isoformat(), - "condition": hourly_forecast.condition, - "temperature": hourly_forecast.temperature, - "precipitation_probability": int((hourly_forecast.precipitation_probability or 0) * 100), - } - for hourly_forecast in weather.hourly_forecasts - ] - - response = httpx.post( - f"{url}/api/states/sensor.van_weather_forecast_hourly", - headers=headers, - json={"state": len(hourly_forecast), "attributes": {"forecast": hourly_forecast}}, - timeout=30, - ) - response.raise_for_status() - - -def update_weather(config: Config) -> None: - """Fetch weather using last-known location, post to HA.""" - 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) - - masked_lat = round(lat, config.mask_decimals) - masked_lon = round(lon, config.mask_decimals) - - logger.info(f"Masked location: {masked_lat}, {masked_lon}") - - weather = fetch_weather(config.pirate_weather_api_key, masked_lat, masked_lon) - logger.info(f"Weather: {weather.temperature}°F, {weather.condition}") - - post_to_ha(config.ha_url, config.ha_token, weather) - logger.info("Posted weather to HA") - - -def main( - ha_url: Annotated[str, typer.Option(envvar="HA_URL")], - ha_token: Annotated[str, typer.Option(envvar="HA_TOKEN")], - api_key: Annotated[str, typer.Option(envvar="PIRATE_WEATHER_API_KEY")], - interval: Annotated[int, typer.Option(help="Poll interval in seconds")] = 900, - log_level: Annotated[str, typer.Option()] = "INFO", -) -> None: - """Fetch weather for van using masked GPS location.""" - configure_logger(log_level) - - config = Config(ha_url=ha_url, ha_token=ha_token, pirate_weather_api_key=api_key) - - logger.info(f"Starting van weather service, polling every {interval}s") - - scheduler = BlockingScheduler() - scheduler.add_job( - update_weather, - "interval", - seconds=interval, - args=[config], - next_run_time=datetime.now(UTC), - ) - scheduler.start() - - -if __name__ == "__main__": - typer.run(main) diff --git a/python/van_weather/models.py b/python/van_weather/models.py deleted file mode 100644 index 671f995..0000000 --- a/python/van_weather/models.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Models for van weather service.""" - -from __future__ import annotations - -from datetime import datetime # noqa: TC003 This is required for pydantic - -from pydantic import BaseModel, field_serializer - - -class Config(BaseModel): - """Service configuration.""" - - ha_url: str - ha_token: str - pirate_weather_api_key: str - lat_entity: str = "sensor.van_last_known_latitude" - lon_entity: str = "sensor.van_last_known_longitude" - mask_decimals: int = 1 # ~11km accuracy - - -class DailyForecast(BaseModel): - """Daily forecast entry.""" - - date_time: datetime - condition: str | None = None - temperature: float | None = None # High - templow: float | None = None # Low - precipitation_probability: float | None = None - moon_phase: float | None = None - wind_gust: float | None = None - cloud_cover: float | None = None - - @field_serializer("date_time") - def serialize_date_time(self, date_time: datetime) -> str: - """Serialize datetime to ISO format.""" - return date_time.isoformat() - - -class HourlyForecast(BaseModel): - """Hourly forecast entry.""" - - date_time: datetime - condition: str | None = None - temperature: float | None = None - precipitation_probability: float | None = None - - @field_serializer("date_time") - def serialize_date_time(self, date_time: datetime) -> str: - """Serialize datetime to ISO format.""" - return date_time.isoformat() - - -class Weather(BaseModel): - """Weather data from Pirate Weather.""" - - temperature: float | None = None - feels_like: float | None = None - humidity: float | None = None - wind_speed: float | None = None - wind_bearing: float | None = None - condition: str | None = None - summary: str | None = None - pressure: 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] = [] - hourly_forecasts: list[HourlyForecast] = []