diff --git a/README.md b/README.md index 6a699ad..f4ee913 100644 --- a/README.md +++ b/README.md @@ -8,27 +8,31 @@ Sensors that recommend clothing based on the weather forecast. - Display names (clothing items) configurable per sensor. - Criteria configurable per sensor. - Default values. -- Works with both daily forecasts and hourly forcasts. +- Works with both daily forecasts and hourly foercasts. - Configurable time ranges for criteria per sensor. +- Provides both a sensor and a binary_sensor platform. -## Configuration +## Sensor Configuration - __name__: Friendly name of the sensor. - __entity_id__: Weather entity supplying the forcast (either _hourly_ or _daily_). -- __unique_id__: Unique ID for customizing the sensor. (_Optional_) -- __selector__: A series of clothing items and the conditions under which they - would be appropriate. See [Selector](#Selector) for more details. +- __unique_id__: Unique ID for customizing the sensor. + (_Optional_) +- __conditions__: A series of clothing items and the conditions under which they + would be appropriate. See [Sensor Conditons](#sensor-conditions) for more + details. (_Optional_ but if you don't use it, then you must have __default__) -- __default__: One of the default sets of selectors. See [Default](#Default) for - more details. (_Optional_ but if you don't use it, then you must have - __selector__) +- __default__: One of the default sets of conditions. See [Sensor Defaults](#sensor-defaults) + for more details. + (_Optional_ but if you don't use it, then you must have __conditions__) - `jacket` - `pants` - `boots` + > Only avaliable for __sensor__. - __mode__: Either a shorthand or the number of hours to consider from the - forecast. See [Mode](#Mode) for more details. (_Optional_: Defaults to - _hour_) + forecast. See [Mode](#mode) for more details. + (_Optional_: Defaults to _hour_) - `hour` - `day` - `hours: ` @@ -46,7 +50,7 @@ sensor: - platform: clothing name: Pants entity_id: weather.montreal_hourly - selector: + conditions: "Snow Pants": - temperature < -2 "Rain Pants": @@ -67,7 +71,7 @@ sensor: - platform: clothing name: Boots entity_id: weather.montreal_hourly - selector: + conditions: "Winter Boots": - temperature < 5 "Rain Boots": @@ -78,9 +82,9 @@ sensor: hours: 24 ``` -## Selector +## Sensor Conditions -Selector allows you to set appropriate outerwear (or anything else really) based +Conditions allows you to set appropriate outerwear (or anything else really) based on forecast conditions. The format is a named clothing item as a key with a list of weather conditions (criteria) that need to be met for that clothing item to be appropriate. All criteria must be met for the clothing to be recommended. @@ -91,44 +95,13 @@ be appropriate. All criteria must be met for the clothing to be recommended. - ``` -### Criteria -Criteria must be in the form: ` `. - -#### Forecast Key - -The forecast keys are any key that a forecast provides. Current keys are: -- `datetime` (_though this one isn't super helpful_) -- `temperature` -- `condition` -- `precipitation_probability` - -#### Operator - -The standard comparison operators you might expect are supported: -- `<` - Forecast is less than value -- `<=` - Forecast is less than or equal to value -- `==` - Forecast is equal to value -- `>=` - Forecast is greater than or equal to value -- `>` - Forecast is greater than value -- `!=` - Forecast is not equal to value - -#### Value - -Value can be either a `string` or a `float`. This allows the criteria to be, for -example: -`temperature < 5` or `temperature < 5.5` or `condition == sunny` - -> For __string comparisons__, they are just that. So while `cloudy < sunny` is -true, it probably isn't all that useful to do string comparisons with operators -other than `==` and `!=`. - -## Default +## Sensor Defaults There are three defaults with reasonable values for clothing: - Jacket: This is a set of outerwear worn on your upper body. 😉 - The selectors this matches to are: + The conditions this matches are: ```yaml "Winter Jacket": - temperature < 5 @@ -144,7 +117,7 @@ There are three defaults with reasonable values for clothing: - temperature > 20 ``` - Pants: This is a set of outerwear worn on your legs. - The selectors this matches to are: + The conditions this matches are: ```yaml "Snow Pants": - temperature < -2 @@ -157,7 +130,7 @@ There are three defaults with reasonable values for clothing: - temperature >= 17 ``` - Boots: These are things to wear on your feet. - The selectors this matches to are: + The conditions this matches are: ```yaml "Winter Boots": - temperature < 5 @@ -167,6 +140,81 @@ There are three defaults with reasonable values for clothing: - temperature >= 5 ``` +## Bianry Sensor Configuration + +- __name__: Friendly name of the sensor. +- __entity_id__: Weather entity supplying the forcast (either _hourly_ or + _daily_). +- __unique_id__: Unique ID for customizing the sensor. + (_Optional_) +- __conditions__: A list of conditions for which the sensor will be set. See + [Binary Sensor Conditons](#binary-sensor-conditions) for more details. + (_Optional_ but if you don't use it, then you must have __default__) +- __mode__: Either a shorthand or the number of hours to consider from the + forecast. See [Mode](#mode) for more details. + (_Optional_: Defaults to _hour_) + - `hour` + - `day` + - `hours: ` + +_Example_: + +```yaml +binary_sensor: + - platform: clothing + name: Mow the Grass now + entity_id: weather.ottawa_richmond_metcalfe_hourly + unique_id: mow_grass + conditions: + - temperature > 5 + - precipitation_probability < 20 + mode: + hours: 4 +``` + +## Binary Sensor Conditions + +Conditions allows you to set the binary sensor based on the forecast. The format +is a list of weather conditions (criteria) that need to be met for binary sensor +to be set. All criteria must be met for the binary sensor to be set. + +```yaml +- +- +``` + +## Criteria + +Criteria must be in the form: ` `. + +### Forecast Key + +The forecast keys are any key that a forecast provides. Current keys are: +- `datetime` (_though this one isn't super helpful_) +- `temperature` +- `condition` +- `precipitation_probability` + +### Operator + +The standard comparison operators you might expect are supported: +- `<` - Forecast is less than value +- `<=` - Forecast is less than or equal to value +- `==` - Forecast is equal to value +- `>=` - Forecast is greater than or equal to value +- `>` - Forecast is greater than value +- `!=` - Forecast is not equal to value + +### Value + +Value can be either a `string` or a `float`. This allows the criteria to be, for +example: +`temperature < 5` or `temperature < 5.5` or `condition == sunny` + +> For __string comparisons__, they are just that. So while `cloudy < sunny` is +true, it probably isn't all that useful to do string comparisons with operators +other than `==` and `!=`. + ## Mode There are several modes that determine how many hours of the forecast to diff --git a/binary_sensor.py b/binary_sensor.py new file mode 100644 index 0000000..6e83247 --- /dev/null +++ b/binary_sensor.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import logging +from math import ceil +from typing import Any + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.const import ( + CONF_CONDITIONS, + CONF_ENTITY_ID, + CONF_MODE, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import Event, HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from weather_clothing.clothing_item import ClothingItem +from weather_clothing.comparisons import operator_map as om + +from .const import ATTR_FORECAST, CONF_HOURS, MIN_CONFIDENCE, OPTION_DAY, OPTION_HOUR +from .helpers import hours_from_forecast + +_LOGGER = logging.getLogger(__name__) + +# Validation of the user's configuration +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(WEATHER_DOMAIN), + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_CONDITIONS): [str], + vol.Optional(CONF_MODE, default=OPTION_HOUR): vol.Any( + OPTION_DAY, OPTION_HOUR, {CONF_HOURS: vol.Range(min=1, max=24)} + ), + } +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the binary sensor platform.""" + + name = config[CONF_NAME] + mode = config[CONF_MODE] + if mode == OPTION_HOUR: + hours = 1 + elif mode == OPTION_DAY: + hours = 10 + else: + hours = mode[CONF_HOURS] + + entity_id = config[CONF_ENTITY_ID] + unique_id = config.get(CONF_UNIQUE_ID) + + conditions = config[CONF_CONDITIONS] + + binary_sensor = ForecastBinarySensor(name, hours, conditions, unique_id) + + async_track_state_change_event(hass, entity_id, binary_sensor.listen_event) + + add_entities([binary_sensor]) + + +class ForecastBinarySensor(BinarySensorEntity): + """Representation of a Forecast Binary Sensor.""" + + def __init__( + self, + name: str, + hours: int, + conditions: list[str], + unique_id: str | None, + ) -> None: + self._attr_name = name + self._hours = hours + self._conditions = conditions + self._attr_unique_id = unique_id + self._state: bool | None = None + self._confidence: float = 0 + self._n: int = 0 + + def predict(self, forecast: list[dict[str, Any]]) -> None: + """Predict if the forecast meets the conditions.""" + # TODO: This is an ass backwards way of setting the minimum confidence. + # It might be better to update the weather_clothing library. + min_count = ceil(MIN_CONFIDENCE * len(forecast)) + + comparisons = [ + om.comparison_from_string(comparison) for comparison in self._conditions + ] + criteria = ClothingItem("", 0, comparisons, min_count) + + for prediction in forecast: + try: + criteria.meets_criteria(prediction, auto=True) + except TypeError as err: + _LOGGER.error("WTF: Error: %s, Forecast: %s", err, forecast) + + self._state = criteria.value is not None + # confidence from the library is how confident that the state is true, + # so if the state is false then the confidence is the inverse of the + # confidence that the state is true, since the confidence is not how + # confident we are that the state is correct, not that it is true. + self._confidence = ( + criteria.confidence if self._state else (1 - criteria.confidence) + ) + self._n = criteria.n + + def listen_event(self, event: Event) -> None: + """Callback for when an event occurs.""" + new_state: State = event.data["new_state"] + + if ( + new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) + or (forecast := new_state.attributes.get(ATTR_FORECAST)) is None + ): + self._state = None + return + + forecast = hours_from_forecast(forecast, self._hours) + self.predict(forecast) + + def update(self) -> None: + """Fetch new state data for the sensor.""" + self._attr_is_on = self._state + + if self._state is None: + self._attr_extra_state_attributes = {} + return + + self._attr_extra_state_attributes = { + "confidence": self._confidence, + "n": self._n, + } diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..5eddb41 --- /dev/null +++ b/helpers.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any + +from homeassistant.components.weather import ATTR_FORECAST_TIME +from homeassistant.util import dt as dt_util +from weather_clothing.clothing_item import ClothingItem +from weather_clothing.comparisons import operator_map as om + + +def clothing_from_config( + config: dict[str, Any], min_count: int = 1 +) -> list[ClothingItem]: + """Convert a dictionary of clothing items and criteria into a list of + ClothingItems. + """ + clothing_items: list[ClothingItem] = [] + priority: int = 0 + for item in config: + comparisons = [ + om.comparison_from_string(comparison) for comparison in config[item] + ] + clothing_items.append(ClothingItem(item, priority, comparisons, min_count)) + priority += 1 + return clothing_items + + +def hours_from_forecast( + forecast: list[dict[str, Any]], hours: int = 1 +) -> list[dict[str, Any]]: + """Get the hours of the forecast that are relent.""" + now = dt_util.now() + diff = timedelta(hours=hours) + + trimmed_forecast: list[dict[str, Any]] = [] + + for prediction in forecast: + # Environment Canada integration returns datetime objects for hourly + # forecast, and isoformatted strings for daily forecasts. So fix them + # here. + # TODO: Fix the Environment Canada integration. + if isinstance(prediction[ATTR_FORECAST_TIME], datetime): + prediction_time: datetime = prediction[ATTR_FORECAST_TIME] + prediction[ATTR_FORECAST_TIME] = prediction_time.isoformat() + elif isinstance(prediction[ATTR_FORECAST_TIME], str): + prediction_time = datetime.fromisoformat(prediction[ATTR_FORECAST_TIME]) + else: + raise ValueError( + f"forecast '{ATTR_FORECAST_TIME}' should be an iso formatted datetime string" + ) + if prediction_time - now < diff: + trimmed_forecast.append(prediction) + + return trimmed_forecast diff --git a/manifest.json b/manifest.json index de5e31a..2a13799 100644 --- a/manifest.json +++ b/manifest.json @@ -12,5 +12,5 @@ "@duncanvanzyl" ], "iot_class": "calculated", - "version": "0.2.0" + "version": "0.3.0" } \ No newline at end of file diff --git a/sensor.py b/sensor.py index 3400dcd..e6527b7 100644 --- a/sensor.py +++ b/sensor.py @@ -2,7 +2,6 @@ import logging from collections import OrderedDict -from datetime import datetime, timedelta from math import ceil from typing import Any, Optional @@ -13,14 +12,14 @@ SensorEntity, SensorStateClass, ) -from homeassistant.components.weather import ATTR_FORECAST, ATTR_FORECAST_TIME +from homeassistant.components.weather import ATTR_FORECAST from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.const import ( + CONF_CONDITIONS, CONF_DEFAULT, CONF_ENTITY_ID, CONF_MODE, CONF_NAME, - CONF_SELECTOR, CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -30,9 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt as dt_util from weather_clothing.clothing_item import ClothingItem -from weather_clothing.comparisons import operator_map as om from .const import ( ATTR_FORECAST, @@ -48,6 +45,7 @@ OPTION_JACKET, OPTION_PANTS, ) +from .helpers import clothing_from_config, hours_from_forecast _LOGGER = logging.getLogger(__name__) @@ -57,7 +55,7 @@ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ENTITY_ID): cv.entity_domain(WEATHER_DOMAIN), vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Exclusive(CONF_SELECTOR, CONF_SELECTOR_KEY): OrderedDict([(str, [str])]), + vol.Exclusive(CONF_CONDITIONS, CONF_SELECTOR_KEY): OrderedDict([(str, [str])]), vol.Exclusive(CONF_DEFAULT, CONF_SELECTOR_KEY): vol.Any( OPTION_JACKET, OPTION_PANTS, OPTION_BOOTS ), @@ -89,71 +87,25 @@ async def async_setup_platform( entity_id = config[CONF_ENTITY_ID] unique_id = config.get(CONF_UNIQUE_ID) - selector = config.get(CONF_SELECTOR) - if selector is None: + conditions = config.get(CONF_CONDITIONS) + if conditions is None: default = config[CONF_DEFAULT] if default == OPTION_JACKET: - selector = DEFAULT_JACKET_CONFIG + conditions = DEFAULT_JACKET_CONFIG elif default == OPTION_PANTS: - selector = DEFAULT_PANTS_CONFIG + conditions = DEFAULT_PANTS_CONFIG elif default == OPTION_BOOTS: - selector = DEFAULT_BOOTS_CONFIG + conditions = DEFAULT_BOOTS_CONFIG else: raise IntegrationError(f"Could not create clothing sensor: {name}") - sensor = ClothingSensor(name, hours, selector, unique_id) + sensor = ClothingSensor(name, hours, conditions, unique_id) async_track_state_change_event(hass, entity_id, sensor.listen_event) add_entities([sensor]) -def clothing_from_config( - config: dict[str, Any], min_count: int = 1 -) -> list[ClothingItem]: - """Convert a dictionary of clothing items and criteria into a list of - ClothingItems. - """ - clothing_items: list[ClothingItem] = [] - priority: int = 0 - for item in config: - comparisons = [ - om.comparison_from_string(comparison) for comparison in config[item] - ] - clothing_items.append(ClothingItem(item, priority, comparisons, min_count)) - priority += 1 - return clothing_items - - -def hours_from_forecast( - forecast: list[dict[str, Any]], hours: int = 1 -) -> list[dict[str, Any]]: - """Get the hours of the forecast that are relent.""" - now = dt_util.now() - diff = timedelta(hours=hours) - - trimmed_forecast: list[dict[str, Any]] = [] - - for prediction in forecast: - # Environment Canada integration returns datetime objects for hourly - # forecast, and isoformatted strings for daily forecasts. So fix them - # here. - # TODO: Fix the Environment Canada integration. - if isinstance(prediction[ATTR_FORECAST_TIME], datetime): - prediction_time: datetime = prediction[ATTR_FORECAST_TIME] - prediction[ATTR_FORECAST_TIME] = prediction_time.isoformat() - elif isinstance(prediction[ATTR_FORECAST_TIME], str): - prediction_time = datetime.fromisoformat(prediction[ATTR_FORECAST_TIME]) - else: - raise ValueError( - f"forecast '{ATTR_FORECAST_TIME}' should be an iso formatted datetime string" - ) - if prediction_time - now < diff: - trimmed_forecast.append(prediction) - - return trimmed_forecast - - class ClothingSensor(SensorEntity): """Representation of a Clothing Sensor.""" @@ -167,12 +119,12 @@ def __init__( self, name: str, hours: int, - clothing_config: OrderedDict[str, list[str]], + conditions: OrderedDict[str, list[str]], unique_id: Optional[str], ) -> None: self._attr_name = name self._hours = hours - self._clothing_config = clothing_config + self._conditions = conditions self._attr_unique_id = unique_id def predict(self, forecast: list[dict[str, Any]]) -> None: @@ -181,14 +133,15 @@ def predict(self, forecast: list[dict[str, Any]]) -> None: # It might be better to update the weather_clothing library. min_count = ceil(MIN_CONFIDENCE * len(forecast)) - items: list[ClothingItem] = clothing_from_config( - self._clothing_config, min_count - ) + items: list[ClothingItem] = clothing_from_config(self._conditions, min_count) for item in items: for prediction in forecast: - if item.meets_criteria(prediction): - item.inc() + try: + if item.meets_criteria(prediction): + item.inc() + except TypeError as err: + _LOGGER.error("WTF: Error: %s, Forecast: %s", err, forecast) if item.value is not None: self._clothing = item.name self._confidence = item.confidence