From 2bc308f6ee85e0f0faeddf315ad6eab6e435f17c Mon Sep 17 00:00:00 2001 From: Kev Date: Tue, 8 Oct 2024 15:24:34 -0400 Subject: [PATCH] Remove forecastio dependency & Fire Index value sensor (#326) Co-authored-by: Alexander Rey Co-authored-by: alexander0042 --- custom_components/pirateweather/const.py | 1 + .../pirateweather/forecast_models.py | 163 ++++++++++++++++++ custom_components/pirateweather/manifest.json | 5 +- custom_components/pirateweather/sensor.py | 42 ++++- .../weather_update_coordinator.py | 2 +- 5 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 custom_components/pirateweather/forecast_models.py diff --git a/custom_components/pirateweather/const.py b/custom_components/pirateweather/const.py index 99a36e5..f186523 100644 --- a/custom_components/pirateweather/const.py +++ b/custom_components/pirateweather/const.py @@ -118,6 +118,7 @@ "time": "Time", "fire_index": "Fire Index", "fire_index_max": "Fire Index Max", + "fire_risk_level": "Fire Risk Level", "smoke": "Smoke", "smoke_max": "Smoke Max", "liquid_accumulation": "Liquid Accumulation", diff --git a/custom_components/pirateweather/forecast_models.py b/custom_components/pirateweather/forecast_models.py new file mode 100644 index 0000000..d54f381 --- /dev/null +++ b/custom_components/pirateweather/forecast_models.py @@ -0,0 +1,163 @@ +"""Taken from the fantastic Dark Sky library: https://github.com/ZeevG/python-forecast.io in October 2024.""" + +import datetime + +import requests + + +class UnicodeMixin: + """Provide string representation for Python 2/3 compatibility.""" + + def __str__(self): + """Return the unicode representation of the object for Python 2/3 compatibility.""" + return self.__unicode__() + + +class PropertyUnavailable(AttributeError): + """Raise when a requested property is unavailable in the forecast data.""" + + +class Forecast(UnicodeMixin): + """Represent the forecast data and provide methods to access weather blocks.""" + + def __init__(self, data, response, headers): + """Initialize the Forecast with data, HTTP response, and headers.""" + self.response = response + self.http_headers = headers + self.json = data + + self._alerts = [] + for alertJSON in self.json.get("alerts", []): + self._alerts.append(Alert(alertJSON)) + + def update(self): + """Update the forecast data by making a new request to the same URL.""" + r = requests.get(self.response.url) + self.json = r.json() + self.response = r + + def currently(self): + """Return the current weather data block.""" + return self._forcastio_data("currently") + + def minutely(self): + """Return the minutely weather data block.""" + return self._forcastio_data("minutely") + + def hourly(self): + """Return the hourly weather data block.""" + return self._forcastio_data("hourly") + + def daily(self): + """Return the daily weather data block.""" + return self._forcastio_data("daily") + + def offset(self): + """Return the time zone offset for the forecast location.""" + return self.json["offset"] + + def alerts(self): + """Return the list of alerts issued for this forecast.""" + return self._alerts + + def _forcastio_data(self, key): + """Fetch and return specific weather data (currently, minutely, hourly, daily).""" + keys = ["minutely", "currently", "hourly", "daily"] + try: + if key not in self.json: + keys.remove(key) + url = "{}&exclude={}{}".format( + self.response.url.split("&")[0], + ",".join(keys), + ",alerts,flags", + ) + + response = requests.get(url).json() + self.json[key] = response[key] + + if key == "currently": + return ForecastioDataPoint(self.json[key]) + return ForecastioDataBlock(self.json[key]) + except requests.HTTPError: + if key == "currently": + return ForecastioDataPoint() + return ForecastioDataBlock() + + +class ForecastioDataBlock(UnicodeMixin): + """Represent a block of weather data such as minutely, hourly, or daily summaries.""" + + def __init__(self, d=None): + """Initialize the data block with summary and icon information.""" + d = d or {} + self.summary = d.get("summary") + self.icon = d.get("icon") + self.data = [ForecastioDataPoint(datapoint) for datapoint in d.get("data", [])] + + def __unicode__(self): + """Return a string representation of the data block.""" + return "" % ( + self.summary, + len(self.data), + ) + + +class ForecastioDataPoint(UnicodeMixin): + """Represent a single data point in a weather forecast, such as an hourly or daily data point.""" + + def __init__(self, d={}): + """Initialize the data point with timestamp and weather information.""" + self.d = d + + try: + self.time = datetime.datetime.fromtimestamp(int(d["time"])) + self.utime = d["time"] + except KeyError: + pass + + try: + sr_time = int(d["sunriseTime"]) + self.sunriseTime = datetime.datetime.fromtimestamp(sr_time) + except KeyError: + self.sunriseTime = None + + try: + ss_time = int(d["sunsetTime"]) + self.sunsetTime = datetime.datetime.fromtimestamp(ss_time) + except KeyError: + self.sunsetTime = None + + def __getattr__(self, name): + """Return the weather property dynamically or raise PropertyUnavailable if missing.""" + try: + return self.d[name] + except KeyError as err: + raise PropertyUnavailable( + f"Property '{name}' is not valid" + " or is not available for this forecast" + ) from err + + def __unicode__(self): + """Return a string representation of the data point.""" + return "" + + +class Alert(UnicodeMixin): + """Represent a weather alert, such as a storm warning or flood alert.""" + + def __init__(self, json): + """Initialize the alert with the raw JSON data.""" + self.json = json + + def __getattr__(self, name): + """Return the alert property dynamically or raise PropertyUnavailable if missing.""" + try: + return self.json[name] + except KeyError as err: + raise PropertyUnavailable( + f"Property '{name}' is not valid" " or is not available for this alert" + ) from err + + def __unicode__(self): + """Return a string representation of the alert.""" + return f"" diff --git a/custom_components/pirateweather/manifest.json b/custom_components/pirateweather/manifest.json index 42d0223..7c85c15 100644 --- a/custom_components/pirateweather/manifest.json +++ b/custom_components/pirateweather/manifest.json @@ -9,8 +9,5 @@ "documentation": "https://github.com/alexander0042/pirate-weather-ha", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/alexander0042/pirate-weather-ha/issues", - "requirements": [ - "python-forecastio==1.4.0" - ], - "version": "1.5.9" + "version": "1.6.0" } diff --git a/custom_components/pirateweather/sensor.py b/custom_components/pirateweather/sensor.py index c186e53..f6ba05d 100644 --- a/custom_components/pirateweather/sensor.py +++ b/custom_components/pirateweather/sensor.py @@ -385,6 +385,14 @@ class PirateWeatherSensorEntityDescription(SensorEntityDescription): icon="mdi:fire", forecast_mode=["currently", "hourly"], ), + "fire_risk_level": PirateWeatherSensorEntityDescription( + key="fire_risk_level", + name="Fire Risk Level", + device_class=SensorDeviceClass.ENUM, + icon="mdi:fire", + forecast_mode=["currently", "hourly", "daily"], + options=["Extreme", "Very High", "High", "Moderate", "Low", "N/A"], + ), "fire_index_max": PirateWeatherSensorEntityDescription( key="fire_index_max", name="Fire Index Max", @@ -1125,8 +1133,17 @@ def get_state(self, data): If the sensor type is unknown, the current state is returned. """ - lookup_type = convert_to_camel(self.type) - state = data.get(lookup_type) + + if self.type == "fire_risk_level": + if self.forecast_hour is not None: + state = data.get("fireIndex") + elif self.forecast_day is not None: + state = data.get("fireIndexMax") + else: + state = data.get("fireIndex") + else: + lookup_type = convert_to_camel(self.type) + state = data.get(lookup_type) if state is None: return state @@ -1214,6 +1231,8 @@ def get_state(self, data): ]: outState = datetime.datetime.fromtimestamp(state, datetime.UTC) + elif self.type == "fire_risk_level": + outState = fire_index(state) elif self.type in [ "dew_point", "temperature", @@ -1277,3 +1296,22 @@ def convert_to_camel(data): components = data.split("_") capital_components = "".join(x.title() for x in components[1:]) return f"{components[0]}{capital_components}" + + +def fire_index(fire_index): + """Convert numeric fire index to a textual value.""" + + if fire_index == -999: + outState = "N/A" + elif fire_index >= 30: + outState = "Extreme" + elif fire_index >= 20: + outState = "Very High" + elif fire_index >= 10: + outState = "High" + elif fire_index >= 5: + outState = "Moderate" + else: + outState = "Low" + + return outState diff --git a/custom_components/pirateweather/weather_update_coordinator.py b/custom_components/pirateweather/weather_update_coordinator.py index 5cea7b0..5b5a2c8 100644 --- a/custom_components/pirateweather/weather_update_coordinator.py +++ b/custom_components/pirateweather/weather_update_coordinator.py @@ -6,12 +6,12 @@ import aiohttp import async_timeout -from forecastio.models import Forecast from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( DOMAIN, ) +from .forecast_models import Forecast _LOGGER = logging.getLogger(__name__)