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