Skip to content

Commit ed8f5fb

Browse files
authored
Merge pull request #130 from Teagan42/main
feat(sensors): Automatic aggregation of more sensor types
2 parents dadeb53 + 1401367 commit ed8f5fb

File tree

19 files changed

+876
-285
lines changed

19 files changed

+876
-285
lines changed

.devcontainer.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,14 @@
1717
"ms-python.python",
1818
"github.vscode-pull-request-github",
1919
"ryanluker.vscode-coverage-gutters",
20-
"ms-python.vscode-pylance"
20+
"ms-python.vscode-pylance",
21+
"ms-python.pylint"
2122
],
2223
"settings": {
2324
"files.eol": "\n",
2425
"editor.tabSize": 4,
2526
"python.pythonPath": "/usr/bin/python3",
2627
"python.analysis.autoSearchPaths": false,
27-
"python.linting.pylintEnabled": true,
28-
"python.linting.enabled": true,
2928
"python.formatting.provider": "black",
3029
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
3130
"editor.formatOnPaste": false,

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,19 @@ A new switch with ID `switch.area_presence_lock_{area_name}` is created for each
6363

6464
### Aggregated illuminance
6565

66-
Tracks all illuminance measuring sensors in an area. The last known illuminance of all sensors is used.
66+
Tracks all illuminance measuring sensors in an area. By default the last known illuminance of all sensors is used.
6767
This illuminance is published in a `sensor` with the ID `sensor.area_illuminance_{area_name}`.
6868

69+
### Aggregated temperature
70+
71+
Tracks all temperature measuring sensors in an area. By default the average of known temperature of all sensors is used.
72+
This temperature is published in a `sensor` with the ID `sensor.area_temperature_{area_name}`.
73+
74+
### Aggregated humidity
75+
76+
Tracks all humidity measuring sensors in an area. By default the maximum of known humidity of all sensors is used.
77+
This humidity is published in a `sensor` with the ID `sensor.area_humidity_{area_name}`.
78+
6979
### Control lights automatically
7080

7181
Lights are automatically turned on and off based on presence in an area.
@@ -84,12 +94,20 @@ A switch with ID `switch.area_sleep_mode_{area_name}` is created for each sleepi
8494

8595
For information on how to mark an area as "sleeping area" refer to the [configuration section](#configuration).
8696

97+
#### Calculation methods
98+
99+
- `mean` - The arithmetic mean of all sensor states that are available and have a numeric value.
100+
- `median` - The arithmetic median of all sensor states that are available and have a numeric value.
101+
- `min` - The minimum of all sensor states that are available and have a numeric value.
102+
- `max` - The maximum of all sensor states that are available and have a numeric value.
103+
- `last` - The last updated of all sensor states that is available and has a numeric value.
104+
87105
## Installation
88106

89107
Auto Areas is a custom_component for Home Assistant.
90108

91109
1. The recommended installation method is using [HACS](https://hacs.xyz): search for "Auto Areas", install it and restart Home Assistant.
92-
Alternatively [download a release](https://github.com/c-st/auto_areas/releases) and copy the folder `custom_components/auto_areas` to the `custom_components` folder of your Home Assistant installation.
110+
Alternatively [download a release](https://github.com/c-st/auto_areas/releases) and copy the folder `custom_components/auto_areas` to the `custom_components` folder of your Home Assistant installation.
93111
2. For each area that you want to control with Auto Areas, go to "Settings"/"Devices & Services"/"Add integration" and search for "Auto Areas". You can then create an instance for each area you want to manage.
94112

95113
## Configuration
@@ -101,6 +119,9 @@ Navigate to "Settings"/"Devices & Services"/"Auto Areas" and select the area for
101119
| Set as sleeping area | Mark area as sleeping area. A switch for controlling sleep mode is created. [See more](#sleep-mode) | `false` (disabled) |
102120
| Excluded light entities | Entities to exclude from automatic light control. These lights are never turned on or off. | `[]` (none) |
103121
| Illuminance threshold | Only if area illuminance is lower than this threshold, lights are turned on. | `0` |
122+
| Illuminance calculation | Configure the calculation for the aggregate illuminance sensor. | `last` |
123+
| Temperature calculation | Configure the calculation for the aggregate temperature sensor. | `mean` |
124+
| Humditity calculation | Configure the calculation for the aggregate humidity sensor. | `max` |
104125

105126
## Development
106127

config/configuration.yaml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# https://www.home-assistant.io/integrations/default_config/
22
default_config:
33

4-
# auto_areas:
5-
64
# https://www.home-assistant.io/integrations/logger/
75
logger:
86
default: info
@@ -47,4 +45,3 @@ sensor:
4745
icon_template: mdi:brightness-auto
4846
unit_of_measurement: lx
4947
value_template: "{{ states('sensor.random_sensor') | float * 0.9 }}"
50-
device_class: illuminance

custom_components/auto_areas/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import asyncio
44

55
from homeassistant.helpers import issue_registry
6-
from homeassistant.config_entries import ConfigEntry, ConfigType
6+
from homeassistant.helpers.typing import ConfigType
7+
from homeassistant.config_entries import ConfigEntry
78
from homeassistant.const import Platform
89
from homeassistant.core import HomeAssistant, callback
910
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
@@ -27,11 +28,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
2728
hass.data[DOMAIN][entry.entry_id] = auto_area
2829

2930
if hass.is_running:
30-
"""Initialize immediately"""
31+
# Initialize immediately
3132
await async_init(hass, entry, auto_area)
3233
else:
33-
"""Schedule initialization when HA is started and initialized"""
34-
34+
# Schedule initialization when HA is started and initialized
3535
# https://developers.home-assistant.io/docs/asyncio_working_with_async/#calling-async-functions-from-threads
3636

3737
@callback

custom_components/auto_areas/auto_area.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
"""Core entity functionality."""
1+
"""Core area functionality."""
22
from __future__ import annotations
33
from homeassistant.core import HomeAssistant
44
from homeassistant.helpers.area_registry import async_get as async_get_area_registry
55
from homeassistant.helpers.device_registry import async_get as async_get_device_registry
66
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
7-
8-
7+
from homeassistant.helpers.issue_registry import async_create_issue, IssueSeverity
98
from homeassistant.config_entries import ConfigEntry
109

1110
from homeassistant.helpers.area_registry import AreaEntry
@@ -15,7 +14,13 @@
1514

1615
from .ha_helpers import get_all_entities, is_valid_entity
1716

18-
from .const import LOGGER, RELEVANT_DOMAINS
17+
from .const import (
18+
CONFIG_AREA,
19+
DOMAIN,
20+
ISSUE_TYPE_INVALID_AREA,
21+
LOGGER,
22+
RELEVANT_DOMAINS,
23+
)
1924

2025

2126
class AutoAreasError(Exception):
@@ -37,21 +42,42 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
3742
self.device_registry = async_get_device_registry(self.hass)
3843
self.entity_registry = async_get_entity_registry(self.hass)
3944

40-
self.area_id: str = entry.data.get("area")
41-
self.area: AreaEntry = self.area_registry.async_get_area(self.area_id)
45+
self.area_id: str | None = entry.data.get(CONFIG_AREA, None)
46+
self.area: AreaEntry | None = self.area_registry.async_get_area(
47+
self.area_id or ""
48+
)
49+
if self.area_id is None or self.area is None:
50+
async_create_issue(
51+
hass,
52+
DOMAIN,
53+
f"{ISSUE_TYPE_INVALID_AREA}_{entry.entry_id}",
54+
is_fixable=True,
55+
severity=IssueSeverity.ERROR,
56+
translation_key=ISSUE_TYPE_INVALID_AREA,
57+
data={
58+
"entry_id": entry.entry_id
59+
}
60+
)
61+
4262
self.auto_lights = None
4363

4464
async def async_initialize(self):
4565
"""Subscribe to area changes and reload if necessary."""
46-
LOGGER.info("%s: Initializing after HA start", self.area.name)
66+
LOGGER.info(
67+
"%s: Initializing after HA start",
68+
self.area_name
69+
)
4770

4871
self.auto_lights = AutoLights(self)
4972
await self.auto_lights.initialize()
5073

5174
def cleanup(self):
5275
"""Deinitialize this area."""
53-
LOGGER.debug("%s: Disabling area control", self.area.name)
54-
if (self.auto_lights):
76+
LOGGER.debug(
77+
"%s: Disabling area control",
78+
self.area_name
79+
)
80+
if self.auto_lights:
5581
self.auto_lights.cleanup()
5682

5783
def get_valid_entities(self) -> list[RegistryEntry]:
@@ -61,9 +87,14 @@ def get_valid_entities(self) -> list[RegistryEntry]:
6187
for entity in get_all_entities(
6288
self.entity_registry,
6389
self.device_registry,
64-
self.area_id,
90+
self.area_id or "",
6591
RELEVANT_DOMAINS,
6692
)
6793
if is_valid_entity(self.hass, entity)
6894
]
6995
return entities
96+
97+
@property
98+
def area_name(self) -> str:
99+
"""Return area name or fallback."""
100+
return self.area.name if self.area is not None else "unknown"
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Base auto-entity class."""
2+
3+
from functools import cached_property
4+
from typing import Generic, TypeVar, cast
5+
6+
from homeassistant.core import Event, EventStateChangedData, State, HomeAssistant
7+
from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE
8+
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
9+
from homeassistant.helpers.entity import Entity
10+
from homeassistant.helpers.device_registry import DeviceInfo
11+
from homeassistant.helpers.typing import StateType
12+
from homeassistant.components.sensor.const import SensorDeviceClass
13+
from homeassistant.helpers.event import async_track_state_change_event
14+
15+
from custom_components.auto_areas.calculations import get_calculation
16+
17+
from .auto_area import AutoArea
18+
from .const import DOMAIN, LOGGER, NAME, VERSION
19+
20+
_TDeviceClass = TypeVar(
21+
"_TDeviceClass", BinarySensorDeviceClass, SensorDeviceClass)
22+
_TEntity = TypeVar("_TEntity", bound=Entity)
23+
24+
25+
class AutoEntity(Entity, Generic[_TEntity, _TDeviceClass]):
26+
"""Set up an aggregated entity."""
27+
28+
def __init__(self,
29+
hass: HomeAssistant,
30+
auto_area: AutoArea,
31+
device_class: _TDeviceClass,
32+
name_prefix: str,
33+
prefix: str
34+
) -> None:
35+
"""Initialize sensor."""
36+
super().__init__()
37+
self.hass = hass
38+
self.auto_area = auto_area
39+
self._device_class = device_class
40+
self._name_prefix = name_prefix
41+
self._prefix = prefix
42+
43+
self.entity_ids: list[str] = self._get_sensor_entities()
44+
self.unsubscribe = None
45+
self.entity_states: dict[str, State] = {}
46+
self._aggregated_state: StateType = None
47+
48+
LOGGER.info(
49+
"%s: Initialized %s sensor",
50+
self.auto_area.area_name,
51+
self.device_class
52+
)
53+
54+
def _get_sensor_entities(self) -> list[str]:
55+
"""Retrieve all relevant entity ids for this sensor."""
56+
return [
57+
entity.entity_id
58+
for entity in self.auto_area.get_valid_entities()
59+
if entity.device_class == self.device_class
60+
or entity.original_device_class == self.device_class
61+
]
62+
63+
@cached_property
64+
def name(self):
65+
"""Name of this entity."""
66+
return f"{self._name_prefix}{self.auto_area.area_name}"
67+
68+
@cached_property
69+
def unique_id(self) -> str:
70+
"""Return a unique ID."""
71+
return f"{self.auto_area.config_entry.entry_id}_aggregated_{self.device_class}"
72+
73+
@cached_property
74+
def device_class(self) -> _TDeviceClass:
75+
"""Return device class."""
76+
return cast(_TDeviceClass, self._device_class)
77+
78+
@cached_property
79+
def device_info(self) -> DeviceInfo:
80+
"""Information about this device."""
81+
return {
82+
"identifiers": {(DOMAIN, self.auto_area.config_entry.entry_id)},
83+
"name": NAME,
84+
"model": VERSION,
85+
"manufacturer": NAME,
86+
"suggested_area": self.auto_area.area_name,
87+
}
88+
89+
async def async_added_to_hass(self):
90+
"""Start tracking sensors."""
91+
LOGGER.debug(
92+
"%s: %s sensor entities: %s",
93+
self.auto_area.area_name,
94+
self.device_class,
95+
self.entity_ids,
96+
)
97+
98+
# Get all current states
99+
for entity_id in self.entity_ids:
100+
state = self.hass.states.get(entity_id)
101+
if state is not None:
102+
try:
103+
self.entity_states[entity_id] = state
104+
except ValueError:
105+
LOGGER.warning(
106+
"No initial state available for %s", entity_id
107+
)
108+
109+
self._aggregated_state = self._get_state()
110+
self.schedule_update_ha_state()
111+
112+
# Subscribe to state changes
113+
self.unsubscribe = async_track_state_change_event(
114+
self.hass,
115+
self.entity_ids,
116+
self._handle_state_change,
117+
)
118+
119+
async def _handle_state_change(self, event: Event[EventStateChangedData]):
120+
"""Handle state change of any tracked illuminance sensors."""
121+
to_state = event.data.get("new_state")
122+
if to_state is None:
123+
return
124+
125+
if to_state.state in [
126+
STATE_UNKNOWN,
127+
STATE_UNAVAILABLE,
128+
]:
129+
self.entity_states.pop(to_state.entity_id, None)
130+
else:
131+
try:
132+
to_state.state = float(to_state.state) # type: ignore
133+
self.entity_states[to_state.entity_id] = to_state
134+
except ValueError:
135+
self.entity_states.pop(to_state.entity_id, None)
136+
137+
self._aggregated_state = self._get_state()
138+
139+
LOGGER.debug(
140+
"%s: got state %s, %d entities",
141+
self.device_class,
142+
str(self.state),
143+
len(self.entity_states.values())
144+
)
145+
146+
self.async_schedule_update_ha_state()
147+
148+
async def async_will_remove_from_hass(self) -> None:
149+
"""Clean up event listeners."""
150+
if self.unsubscribe:
151+
self.unsubscribe()
152+
153+
def _get_state(self) -> StateType | None:
154+
"""Get the state of the sensor."""
155+
calculate_state = get_calculation(
156+
self.auto_area.config_entry.options,
157+
self.device_class
158+
)
159+
if calculate_state is None:
160+
LOGGER.error(
161+
"%s: %s unable to get state calculation method",
162+
self.auto_area.area_name,
163+
self.device_class
164+
)
165+
return None
166+
167+
return calculate_state(list(self.entity_states.values()))

0 commit comments

Comments
 (0)