Skip to content

Commit

Permalink
Add binary sensor platform
Browse files Browse the repository at this point in the history
Change configuration keys "selector" -> "conditions"
  • Loading branch information
duncanvanzyl committed May 2, 2022
1 parent b5a31cf commit ed9717f
Show file tree
Hide file tree
Showing 5 changed files with 315 additions and 115 deletions.
146 changes: 97 additions & 49 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <hours in range 1 - 24>`
Expand All @@ -46,7 +50,7 @@ sensor:
- platform: clothing
name: Pants
entity_id: weather.montreal_hourly
selector:
conditions:
"Snow Pants":
- temperature < -2
"Rain Pants":
Expand All @@ -67,7 +71,7 @@ sensor:
- platform: clothing
name: Boots
entity_id: weather.montreal_hourly
selector:
conditions:
"Winter Boots":
- temperature < 5
"Rain Boots":
Expand All @@ -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.
Expand All @@ -91,44 +95,13 @@ be appropriate. All criteria must be met for the clothing to be recommended.
- <criteria 2>
```
### Criteria
Criteria must be in the form: `<forecast key> <operator> <value>`.
#### 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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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: <hours in range 1 - 24>`

_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 1>
- <criteria 2>
```

## Criteria

Criteria must be in the form: `<forecast key> <operator> <value>`.

### 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
Expand Down
145 changes: 145 additions & 0 deletions binary_sensor.py
Original file line number Diff line number Diff line change
@@ -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,
}
Loading

0 comments on commit ed9717f

Please sign in to comment.