From b1c3ce510327a73a7fcd0626002d0eadad81596a Mon Sep 17 00:00:00 2001 From: tlskinneriv Date: Sat, 9 Sep 2023 22:24:26 -0500 Subject: [PATCH] add weather entity and extra wind dir sensors (#38) ## [1.2.0] - 2023-09-09 ### Added - The following calculated sensors have been added: - Wind Direction Cardinal - Gust Direction Cardinal - BETA: added a weather entity to collect common data in one place and loosely predict the weather condition outside based on data available from the sensor array. The weather condition part of this entity is currently in testing. Please raise issues for any unexpected behavior. --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 18 +- .vscode/settings.json | 3 +- CHANGELOG.md | 11 + README.md | 9 +- custom_components/awnet_local/__init__.py | 2 +- custom_components/awnet_local/const_sensor.py | 20 +- custom_components/awnet_local/helpers_calc.py | 43 ++++ custom_components/awnet_local/manifest.json | 8 +- custom_components/awnet_local/weather.py | 196 ++++++++++++++++++ 10 files changed, 288 insertions(+), 24 deletions(-) create mode 100644 custom_components/awnet_local/weather.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 691fce9..3158644 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -48,7 +48,7 @@ WORKDIR /srv/homeassistant RUN python -m venv . \ && source bin/activate \ && python -m pip install wheel \ - && pip3 install homeassistant + && pip3 install urllib3==1.26.7 homeassistant RUN ./setup.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1377a4a..39d1d3e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,19 +21,8 @@ "vscode": { "settings": { "python.defaultInterpreterPath": "/srv/homeassistant/bin/python", - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, - "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", - "python.formatting.blackPath": "/usr/local/py-utils/bin/black", - "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", - "python.formatting.provider": "black", - "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", - "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", - "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", - "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", - "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", - "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", - "python.linting.pylintArgs": [ + "pylint.path": ["/usr/local/py-utils/bin/pylint"], + "pylint.args": [ "--init-hook", "import sys; sys.path.append('/srv/homeassistant/lib/python3.11/site-packages/')" ], @@ -51,7 +40,8 @@ "esbenp.prettier-vscode", "GitHub.vscode-pull-request-github", "streetsidesoftware.code-spell-checker", - "ms-python.pylint" + "ms-python.pylint", + "ms-python.black-formatter" ] } }, diff --git a/.vscode/settings.json b/.vscode/settings.json index 0b0fb69..a501b60 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,11 +2,10 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "[python]": { - "editor.defaultFormatter": "ms-python.python" + "editor.defaultFormatter": "ms-python.black-formatter" }, "editor.rulers": [100], "rewrap.autoWrap.enabled": true, "rewrap.wrappingColumn": 100, - "python.formatting.provider": "black", "cSpell.words": ["awnet", "hass", "homeassistant"] } diff --git a/CHANGELOG.md b/CHANGELOG.md index e0bfc31..0f5fa1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog], and this project adheres to [Semantic Versioning]. +## [1.2.0] - 2023-09-09 + +### Added + +- The following calculated sensors have been added: + - Wind Direction Cardinal + - Gust Direction Cardinal +- BETA: added a weather entity to collect common data in one place and loosely predict the weather + condition outside based on data available from the sensor array. The weather condition part of + this entity is currently in testing. Please raise issues for any unexpected behavior. + ## [1.1.3] - 2023-07-13 ### Fixed diff --git a/README.md b/README.md index b1a71b2..1971db2 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,14 @@ Configuration is performed via the Home Assistant user interface. You will need - Name: a friendly name for the device to display in Home Assistant - MAC: the MAC address for the device -Once configured, setup the accompanying add-on [AWNET](https://github.com/tlskinneriv/hassio-addons/tree/master/awnet) (see the [docs](https://github.com/tlskinneriv/hassio-addons/blob/master/awnet/DOCS.md) for direct instructions). +Once configured, setup the accompanying add-on +[AWNET](https://github.com/tlskinneriv/hassio-addons/tree/master/awnet) (see the +[docs](https://github.com/tlskinneriv/hassio-addons/blob/master/awnet/DOCS.md) for direct +instructions). + +> NOTE: Entities for the device will not show up until the add-on referenced above is installed and +> the settings are properly configured on the Ambient Weather device. Currently, the integration +> supports only one weather station. ## Service diff --git a/custom_components/awnet_local/__init__.py b/custom_components/awnet_local/__init__.py index c0a63d9..0913ad2 100644 --- a/custom_components/awnet_local/__init__.py +++ b/custom_components/awnet_local/__init__.py @@ -44,7 +44,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.WEATHER] STORAGE_KEY = DOMAIN + "_data" STORAGE_VERSION = 1 diff --git a/custom_components/awnet_local/const_sensor.py b/custom_components/awnet_local/const_sensor.py index 93421b3..6becd20 100644 --- a/custom_components/awnet_local/const_sensor.py +++ b/custom_components/awnet_local/const_sensor.py @@ -140,9 +140,11 @@ TYPE_UV = "uv" TYPE_WEEKLYRAININ = "weeklyrainin" TYPE_WINDDIR = "winddir" +TYPE_WINDDIR_CARD = "winddir_card" TYPE_WINDDIR_AVG10M = "winddir_Avg10m" TYPE_WINDDIR_AVG2M = "winddir_Avg2m" TYPE_WINDGUSTDIR = "windgustdir" +TYPE_WINDGUSTDIR_CARD = "windgustdir_card" TYPE_WINDGUSTMPH = "windgustmph" TYPE_WINDSPDMPH_AVG10M = "windspdmph_Avg10m" TYPE_WINDSPDMPH_AVG2M = "windspdmph_Avg2m" @@ -264,7 +266,9 @@ TYPE_WINDDIR_AVG10M, TYPE_WINDDIR_AVG2M, TYPE_WINDDIR, + TYPE_WINDDIR_CARD, TYPE_WINDGUSTDIR, + TYPE_WINDGUSTDIR_CARD, TYPE_WINDGUSTMPH, TYPE_WINDSPDMPH_AVG10M, TYPE_WINDSPDMPH_AVG2M, @@ -282,6 +286,8 @@ TYPE_FEELSLIKE_IN: [TYPE_TEMPINF, TYPE_HUMIDITYIN], TYPE_DEWPOINT_IN: [TYPE_TEMPINF, TYPE_HUMIDITYIN], TYPE_LIGHTNING_PER_HOUR: [TYPE_LIGHTNING_PER_DAY, TYPE_DATEUTC], + TYPE_WINDDIR_CARD: [TYPE_WINDDIR], + TYPE_WINDGUSTDIR_CARD: [TYPE_WINDGUSTDIR], } # Each sensor listed here is converted server-side from the native unit to the unit that HA supports @@ -1069,11 +1075,16 @@ ), SensorEntityDescription( key=TYPE_WINDDIR, - name="Wind Dir", + name="Wind Direction", icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + key=TYPE_WINDDIR_CARD, + name="Wind Direction Cardinal", + icon="mdi:weather-windy", + ), SensorEntityDescription( key=TYPE_WINDDIR_AVG10M, name="Wind Dir Avg 10m", @@ -1090,11 +1101,16 @@ ), SensorEntityDescription( key=TYPE_WINDGUSTDIR, - name="Gust Dir", + name="Gust Direction", icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + key=TYPE_WINDGUSTDIR_CARD, + name="Gust Direction Cardinal", + icon="mdi:weather-windy", + ), SensorEntityDescription( key=TYPE_WINDGUSTMPH, name="Wind Gust", diff --git a/custom_components/awnet_local/helpers_calc.py b/custom_components/awnet_local/helpers_calc.py index 4a3ff66..346af3c 100644 --- a/custom_components/awnet_local/helpers_calc.py +++ b/custom_components/awnet_local/helpers_calc.py @@ -28,6 +28,10 @@ TYPE_LIGHTNING_PER_HOUR, TYPE_LIGHTNING_PER_DAY, TYPE_DATEUTC, + TYPE_WINDDIR, + TYPE_WINDDIR_CARD, + TYPE_WINDGUSTDIR, + TYPE_WINDGUSTDIR_CARD, ) _LOGGER = logging.getLogger(__name__) @@ -87,6 +91,14 @@ def calculate(entity_key: str, station_data: dict[str, object]) -> object: int(station_values.get(TYPE_LIGHTNING_PER_DAY)), station_data[ATTR_LIGHTNING_DATA], ) + if entity_key == TYPE_WINDDIR_CARD: + return AmbientSensorCalculations.degree_to_cardinal( + float(station_values.get(TYPE_WINDDIR)) + ) + if entity_key == TYPE_WINDGUSTDIR_CARD: + return AmbientSensorCalculations.degree_to_cardinal( + float(station_values.get(TYPE_WINDGUSTDIR)) + ) raise NotImplementedError(f"Calculation for {entity_key} is not implemented") @staticmethod @@ -291,6 +303,37 @@ def dew_point(tempf: float, rel_humid_percent: float) -> float: dew_pt_c = (const_c * gamma_t_rh) / (const_b - gamma_t_rh) return float(round(dew_pt_c * 9 / 5 + 32, 1)) + @staticmethod + def degree_to_cardinal(direction_degree: float) -> str: + """Converts a direction in degrees to its cardinal equivalent + + Args: + direction_degree (float): Direction in degrees + + Returns: + str: Cardinal direction + """ + direction = [ + "N", + "NNE", + "NE", + "ENE", + "E", + "ESE", + "SE", + "SSE", + "S", + "SSW", + "SW", + "WSW", + "W", + "WNW", + "NW", + "NNW", + "N", + ] + return direction[int((direction_degree + 11.25) / 22.5)] + class AmbientSensorConversions: """Class full of static methods for performing conversions from native units to HA units where diff --git a/custom_components/awnet_local/manifest.json b/custom_components/awnet_local/manifest.json index 6848b22..3aeebda 100644 --- a/custom_components/awnet_local/manifest.json +++ b/custom_components/awnet_local/manifest.json @@ -1,7 +1,9 @@ { "domain": "awnet_local", "name": "Ambient Weather Station - Local", - "codeowners": ["@tlskinneriv"], + "codeowners": [ + "@tlskinneriv" + ], "config_flow": true, "dependencies": [], "documentation": "https://github.com/tlskinneriv/awnet_local", @@ -9,5 +11,5 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/tlskinneriv/awnet_local/issues", "requirements": [], - "version": "1.1.3" -} + "version": "1.2.0" +} \ No newline at end of file diff --git a/custom_components/awnet_local/weather.py b/custom_components/awnet_local/weather.py new file mode 100644 index 0000000..32f1485 --- /dev/null +++ b/custom_components/awnet_local/weather.py @@ -0,0 +1,196 @@ +"""Support for Ambient Weather Station weather entity.""" + +from __future__ import annotations + +import logging +from datetime import timedelta + +from homeassistant.components.weather import ( + WeatherEntity, + WeatherEntityDescription, + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_CONDITION_WINDY_VARIANT, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from homeassistant.const import ( + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) + +from . import AmbientWeatherEntity +from .const import ( + ATTR_LAST_DATA, + DOMAIN, +) + +from .const_sensor import ( + CALCULATED_SENSOR_TYPES, + TYPE_BAROMRELIN, + TYPE_DEWPOINT, + TYPE_FEELSLIKE, + TYPE_HUMIDITY, + TYPE_TEMPF, + TYPE_WINDDIR, + TYPE_WINDGUSTMPH, + TYPE_WINDSPEEDMPH, + TYPE_HOURLYRAININ, + TYPE_SOLARRADIATION, + TYPE_LIGHTNING_TIME, + TYPE_DATEUTC, +) + +from .helpers_calc import AmbientSensorCalculations, AmbientSensorConversions + +_LOGGER = logging.getLogger(__name__) + +WEATHER_ATTR_MAPPING = [ + ("native_temperature", float, TYPE_TEMPF), + ("native_apparent_temperature", float, TYPE_FEELSLIKE), + ("native_dew_point", float, TYPE_DEWPOINT), + ("native_pressure", float, TYPE_BAROMRELIN), + ("humidity", float, TYPE_HUMIDITY), + ("native_wind_gust_speed", float, TYPE_WINDGUSTMPH), + ("native_wind_speed", float, TYPE_WINDSPEEDMPH), + ("wind_bearing", float, TYPE_WINDDIR), +] + +WEATHER_POURING_RATE = 0.15 +WEATHER_LIGHT_LEVEL_NIGHT = 0.02 +WEATHER_MODERATE_WINDSPEED = 7.0 +WEATHER_LIGHT_LEVEL_CLOUDY = 200.0 +WEATHER_LIGHT_LEVEL_PARTLYCLOUDY = 100.0 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Ambient PWS sensors based on a config entry.""" + ambient = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + AmbientWeatherWeather( + ambient, + WeatherEntityDescription(key="weather", name="Weather"), + ) + ] + ) + + +class AmbientWeatherWeather(AmbientWeatherEntity, WeatherEntity): + """Define an Ambient Weather weather entity.""" + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self._attr_available = True + + # default unknown condition and temp + self._attr_condition = None + self._attr_native_temperature = None + + # native units + self._attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT + self._attr_native_pressure_unit = UnitOfPressure.INHG + self._attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR + + def set_weather_attr( + self, + attribute: str, + attr_type: type, + key: str, + ) -> None: + """From a set of mapping to attributes, set the appropriate attribute on the entity based on + the sensor key""" + station_data = self._ambient.station[ATTR_LAST_DATA] + if key in CALCULATED_SENSOR_TYPES: + if all( + station_data.get(x) is not None for x in CALCULATED_SENSOR_TYPES[key] + ): + # calculation of sensor values + value = AmbientSensorCalculations.calculate(key, self._ambient.station) + else: + value = None + else: + value = station_data.get(key) + raw = attr_type(value) if value is not None else None + setattr(self, attribute, raw) + + @callback + def update_from_latest_data(self) -> None: + """Fetch new state data for the entity.""" + self._attr_available = True + + # fill condition attributes + for attr_data in WEATHER_ATTR_MAPPING: + self.set_weather_attr("_attr_" + attr_data[0], attr_data[1], attr_data[2]) + + # current condition + + # we can loosely predict the following conditions with the basic sensor data that we get + # from the weather station: clear-night, cloudy, lightning & lightning-rainy (if lightning + # sensor exists), partlycloudy, pouring, rainy, sunny, windy, windy-variant + + # get the data we'll use to determine the condition + station_data = self._ambient.station[ATTR_LAST_DATA] + light_level = float(station_data.get(TYPE_SOLARRADIATION, 0.0)) # w/m^2 + _LOGGER.debug("light level: %s", light_level) + rain_rate = float(station_data.get(TYPE_HOURLYRAININ, 0.0)) # in/hr + _LOGGER.debug("rain rate: %s", rain_rate) + wind_speed = float(station_data.get(TYPE_WINDSPEEDMPH, 0.0)) # mi/hr) + _LOGGER.debug("wind speed: %s", wind_speed) + last_lightning = AmbientSensorConversions.convert( + TYPE_LIGHTNING_TIME, station_data.get(TYPE_LIGHTNING_TIME, 0) + ) + now = AmbientSensorConversions.convert( + TYPE_DATEUTC, station_data.get(TYPE_DATEUTC) + ) + _LOGGER.debug("last_lightning: %s", last_lightning) + _LOGGER.debug("now: %s", now) + + # set binaries for comparisons + is_rainy = rain_rate > 0.0 + is_pouring = rain_rate >= WEATHER_POURING_RATE + is_night = light_level <= WEATHER_LIGHT_LEVEL_NIGHT + is_windy = wind_speed >= WEATHER_MODERATE_WINDSPEED + is_cloudy = ( + light_level >= WEATHER_LIGHT_LEVEL_CLOUDY + and light_level < WEATHER_LIGHT_LEVEL_PARTLYCLOUDY + ) + is_partlycloudy = light_level >= WEATHER_LIGHT_LEVEL_PARTLYCLOUDY + is_lightning = now - last_lightning <= timedelta(minutes=10) + + # start condition as sunny until we determine otherwise + condition = ATTR_CONDITION_SUNNY + if is_partlycloudy: + condition = ATTR_CONDITION_PARTLYCLOUDY + if is_cloudy: + condition = ATTR_CONDITION_CLOUDY + if is_night: + condition = ATTR_CONDITION_CLEAR_NIGHT + if is_windy: + condition = ATTR_CONDITION_WINDY + if is_windy and is_cloudy: + condition = ATTR_CONDITION_WINDY_VARIANT + if is_rainy: + condition = ATTR_CONDITION_RAINY + if is_pouring: + condition = ATTR_CONDITION_POURING + if is_lightning and not is_rainy: + condition = ATTR_CONDITION_LIGHTNING + if is_lightning and is_rainy: + condition = ATTR_CONDITION_LIGHTNING_RAINY + + self._attr_condition = condition