Add device trigger support to sensor entities (#27133)

* Add device trigger support to sensor entities

* Fix typing

* Fix tests, add test helper for comparing lists
This commit is contained in:
Erik Montnemery 2019-10-03 06:14:35 +02:00 committed by GitHub
parent e005f6f23a
commit 3e99743244
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 689 additions and 11 deletions

View file

@ -40,7 +40,9 @@ TRIGGER_SCHEMA = vol.All(
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_attach_trigger(hass, config, action, automation_info): async def async_attach_trigger(
hass, config, action, automation_info, *, platform_type="numeric_state"
):
"""Listen for state changes based on configuration.""" """Listen for state changes based on configuration."""
entity_id = config.get(CONF_ENTITY_ID) entity_id = config.get(CONF_ENTITY_ID)
below = config.get(CONF_BELOW) below = config.get(CONF_BELOW)
@ -84,7 +86,7 @@ async def async_attach_trigger(hass, config, action, automation_info):
action( action(
{ {
"trigger": { "trigger": {
"platform": "numeric_state", "platform": platform_type,
"entity_id": entity, "entity_id": entity,
"below": below, "below": below,
"above": above, "above": above,

View file

@ -195,8 +195,8 @@ async def async_attach_trigger(hass, config, action, automation_info):
state_automation.CONF_FROM: from_state, state_automation.CONF_FROM: from_state,
state_automation.CONF_TO: to_state, state_automation.CONF_TO: to_state,
} }
if "for" in config: if CONF_FOR in config:
state_config["for"] = config["for"] state_config[CONF_FOR] = config[CONF_FOR]
return await state_automation.async_attach_trigger( return await state_automation.async_attach_trigger(
hass, state_config, action, automation_info, platform_type="device" hass, state_config, action, automation_info, platform_type="device"
@ -215,7 +215,7 @@ async def async_get_triggers(hass, device_id):
] ]
for entry in entries: for entry in entries:
device_class = None device_class = DEVICE_CLASS_NONE
state = hass.states.get(entry.entity_id) state = hass.states.get(entry.entity_id)
if state: if state:
device_class = state.attributes.get(ATTR_DEVICE_CLASS) device_class = state.attributes.get(ATTR_DEVICE_CLASS)

View file

@ -155,8 +155,8 @@ async def async_attach_trigger(
state.CONF_FROM: from_state, state.CONF_FROM: from_state,
state.CONF_TO: to_state, state.CONF_TO: to_state,
} }
if "for" in config: if CONF_FOR in config:
state_config["for"] = config["for"] state_config[CONF_FOR] = config[CONF_FOR]
return await state.async_attach_trigger( return await state.async_attach_trigger(
hass, state_config, action, automation_info, platform_type="device" hass, state_config, action, automation_info, platform_type="device"

View file

@ -0,0 +1,145 @@
"""Provides device triggers for sensors."""
import voluptuous as vol
import homeassistant.components.automation.numeric_state as numeric_state_automation
from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA
from homeassistant.const import (
ATTR_DEVICE_CLASS,
CONF_ABOVE,
CONF_BELOW,
CONF_ENTITY_ID,
CONF_FOR,
CONF_TYPE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_POWER,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TIMESTAMP,
)
from homeassistant.helpers.entity_registry import async_entries_for_device
from homeassistant.helpers import config_validation as cv
from . import DOMAIN
# mypy: allow-untyped-defs, no-check-untyped-defs
DEVICE_CLASS_NONE = "none"
CONF_BATTERY_LEVEL = "battery_level"
CONF_HUMIDITY = "humidity"
CONF_ILLUMINANCE = "illuminance"
CONF_POWER = "power"
CONF_PRESSURE = "pressure"
CONF_SIGNAL_STRENGTH = "signal_strength"
CONF_TEMPERATURE = "temperature"
CONF_TIMESTAMP = "timestamp"
CONF_VALUE = "value"
ENTITY_TRIGGERS = {
DEVICE_CLASS_BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}],
DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_HUMIDITY}],
DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_ILLUMINANCE}],
DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_POWER}],
DEVICE_CLASS_PRESSURE: [{CONF_TYPE: CONF_PRESSURE}],
DEVICE_CLASS_SIGNAL_STRENGTH: [{CONF_TYPE: CONF_SIGNAL_STRENGTH}],
DEVICE_CLASS_TEMPERATURE: [{CONF_TYPE: CONF_TEMPERATURE}],
DEVICE_CLASS_TIMESTAMP: [{CONF_TYPE: CONF_TIMESTAMP}],
DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_VALUE}],
}
TRIGGER_SCHEMA = vol.All(
TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Required(CONF_TYPE): vol.In(
[
CONF_BATTERY_LEVEL,
CONF_HUMIDITY,
CONF_ILLUMINANCE,
CONF_POWER,
CONF_PRESSURE,
CONF_SIGNAL_STRENGTH,
CONF_TEMPERATURE,
CONF_TIMESTAMP,
CONF_VALUE,
]
),
vol.Optional(CONF_BELOW): vol.Any(vol.Coerce(float)),
vol.Optional(CONF_ABOVE): vol.Any(vol.Coerce(float)),
vol.Optional(CONF_FOR): vol.Any(
vol.All(cv.time_period, cv.positive_timedelta),
cv.template,
cv.template_complex,
),
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
}
),
cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE),
)
async def async_attach_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
numeric_state_config = {
numeric_state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID],
numeric_state_automation.CONF_ABOVE: config.get(CONF_ABOVE),
numeric_state_automation.CONF_BELOW: config.get(CONF_BELOW),
numeric_state_automation.CONF_FOR: config.get(CONF_FOR),
}
if CONF_FOR in config:
numeric_state_config[CONF_FOR] = config[CONF_FOR]
return await numeric_state_automation.async_attach_trigger(
hass, numeric_state_config, action, automation_info, platform_type="device"
)
async def async_get_triggers(hass, device_id):
"""List device triggers."""
triggers = []
entity_registry = await hass.helpers.entity_registry.async_get_registry()
entries = [
entry
for entry in async_entries_for_device(entity_registry, device_id)
if entry.domain == DOMAIN
]
for entry in entries:
device_class = DEVICE_CLASS_NONE
state = hass.states.get(entry.entity_id)
if state:
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
templates = ENTITY_TRIGGERS.get(
device_class, ENTITY_TRIGGERS[DEVICE_CLASS_NONE]
)
triggers.extend(
(
{
**automation,
"platform": "device",
"device_id": device_id,
"entity_id": entry.entity_id,
"domain": DOMAIN,
}
for automation in templates
)
)
return triggers
async def async_get_trigger_capabilities(hass, trigger):
"""List trigger capabilities."""
return {
"extra_fields": vol.Schema(
{vol.Optional(CONF_FOR): cv.positive_time_period_dict}
)
}

View file

@ -0,0 +1,26 @@
{
"device_automation": {
"condition_type": {
"is_battery_level": "{entity_name} battery level",
"is_humidity": "{entity_name} humidity",
"is_illuminance": "{entity_name} illuminance",
"is_power": "{entity_name} power",
"is_pressure": "{entity_name} pressure",
"is_signal_strength": "{entity_name} signal strength",
"is_temperature": "{entity_name} temperature",
"is_timestamp": "{entity_name} timestamp",
"is_value": "{entity_name} value"
},
"trigger_type": {
"battery_level": "{entity_name} battery level",
"humidity": "{entity_name} humidity",
"illuminance": "{entity_name} illuminance",
"power": "{entity_name} power",
"pressure": "{entity_name} pressure",
"signal_strength": "{entity_name} signal strength",
"temperature": "{entity_name} temperature",
"timestamp": "{entity_name} timestamp",
"value": "{entity_name} value"
}
}
}

View file

@ -1,5 +1,6 @@
"""Test the helper method for writing tests.""" """Test the helper method for writing tests."""
import asyncio import asyncio
import collections
import functools as ft import functools as ft
import json import json
import logging import logging
@ -1050,3 +1051,85 @@ def async_mock_signal(hass, signal):
hass.helpers.dispatcher.async_dispatcher_connect(signal, mock_signal_handler) hass.helpers.dispatcher.async_dispatcher_connect(signal, mock_signal_handler)
return calls return calls
class hashdict(dict):
"""
hashable dict implementation, suitable for use as a key into other dicts.
>>> h1 = hashdict({"apples": 1, "bananas":2})
>>> h2 = hashdict({"bananas": 3, "mangoes": 5})
>>> h1+h2
hashdict(apples=1, bananas=3, mangoes=5)
>>> d1 = {}
>>> d1[h1] = "salad"
>>> d1[h1]
'salad'
>>> d1[h2]
Traceback (most recent call last):
...
KeyError: hashdict(bananas=3, mangoes=5)
based on answers from
http://stackoverflow.com/questions/1151658/python-hashable-dicts
"""
def __key(self): # noqa: D105 no docstring
return tuple(sorted(self.items()))
def __repr__(self): # noqa: D105 no docstring
return ", ".join("{0}={1}".format(str(i[0]), repr(i[1])) for i in self.__key())
def __hash__(self): # noqa: D105 no docstring
return hash(self.__key())
def __setitem__(self, key, value): # noqa: D105 no docstring
raise TypeError(
"{0} does not support item assignment".format(self.__class__.__name__)
)
def __delitem__(self, key): # noqa: D105 no docstring
raise TypeError(
"{0} does not support item assignment".format(self.__class__.__name__)
)
def clear(self): # noqa: D102 no docstring
raise TypeError(
"{0} does not support item assignment".format(self.__class__.__name__)
)
def pop(self, *args, **kwargs): # noqa: D102 no docstring
raise TypeError(
"{0} does not support item assignment".format(self.__class__.__name__)
)
def popitem(self, *args, **kwargs): # noqa: D102 no docstring
raise TypeError(
"{0} does not support item assignment".format(self.__class__.__name__)
)
def setdefault(self, *args, **kwargs): # noqa: D102 no docstring
raise TypeError(
"{0} does not support item assignment".format(self.__class__.__name__)
)
def update(self, *args, **kwargs): # noqa: D102 no docstring
raise TypeError(
"{0} does not support item assignment".format(self.__class__.__name__)
)
# update is not ok because it mutates the object
# __add__ is ok because it creates a new object
# while the new object is under construction, it's ok to mutate it
def __add__(self, right): # noqa: D105 no docstring
result = hashdict(self)
dict.update(result, right)
return result
def assert_lists_same(a, b):
"""Compare two lists, ignoring order."""
assert collections.Counter([hashdict(i) for i in a]) == collections.Counter(
[hashdict(i) for i in b]
)

View file

@ -3,7 +3,7 @@ from copy import deepcopy
from homeassistant.components.deconz import device_trigger from homeassistant.components.deconz import device_trigger
from tests.common import async_get_device_automations from tests.common import assert_lists_same, async_get_device_automations
from .test_gateway import ENTRY_CONFIG, DECONZ_WEB_REQUEST, setup_deconz_integration from .test_gateway import ENTRY_CONFIG, DECONZ_WEB_REQUEST, setup_deconz_integration
@ -83,6 +83,13 @@ async def test_get_triggers(hass):
"type": device_trigger.CONF_LONG_RELEASE, "type": device_trigger.CONF_LONG_RELEASE,
"subtype": device_trigger.CONF_TURN_OFF, "subtype": device_trigger.CONF_TURN_OFF,
}, },
{
"device_id": device_id,
"domain": "sensor",
"entity_id": "sensor.tradfri_on_off_switch_battery_level",
"platform": "device",
"type": "battery_level",
},
] ]
assert triggers == expected_triggers assert_lists_same(triggers, expected_triggers)

View file

@ -0,0 +1,368 @@
"""The test for sensor device automation."""
from datetime import timedelta
import pytest
from homeassistant.components.sensor import DOMAIN, DEVICE_CLASSES
from homeassistant.components.sensor.device_trigger import ENTITY_TRIGGERS
from homeassistant.const import STATE_UNKNOWN, CONF_PLATFORM
from homeassistant.setup import async_setup_component
import homeassistant.components.automation as automation
from homeassistant.helpers import device_registry
import homeassistant.util.dt as dt_util
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
async_mock_service,
mock_device_registry,
mock_registry,
async_get_device_automations,
async_get_device_automation_capabilities,
)
@pytest.fixture
def device_reg(hass):
"""Return an empty, loaded, registry."""
return mock_device_registry(hass)
@pytest.fixture
def entity_reg(hass):
"""Return an empty, loaded, registry."""
return mock_registry(hass)
@pytest.fixture
def calls(hass):
"""Track calls to a mock serivce."""
return async_mock_service(hass, "test", "automation")
async def test_get_triggers(hass, device_reg, entity_reg):
"""Test we get the expected triggers from a sensor."""
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
config_entry = MockConfigEntry(domain="test", data={})
config_entry.add_to_hass(hass)
device_entry = device_reg.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
for device_class in DEVICE_CLASSES:
entity_reg.async_get_or_create(
DOMAIN,
"test",
platform.ENTITIES[device_class].unique_id,
device_id=device_entry.id,
)
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
expected_triggers = [
{
"platform": "device",
"domain": DOMAIN,
"type": trigger["type"],
"device_id": device_entry.id,
"entity_id": platform.ENTITIES[device_class].entity_id,
}
for device_class in DEVICE_CLASSES
for trigger in ENTITY_TRIGGERS[device_class]
]
triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
assert triggers == expected_triggers
async def test_get_trigger_capabilities(hass, device_reg, entity_reg):
"""Test we get the expected capabilities from a binary_sensor trigger."""
config_entry = MockConfigEntry(domain="test", data={})
config_entry.add_to_hass(hass)
device_entry = device_reg.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id)
expected_capabilities = {
"extra_fields": [
{"name": "for", "optional": True, "type": "positive_time_period_dict"}
]
}
triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
for trigger in triggers:
capabilities = await async_get_device_automation_capabilities(
hass, "trigger", trigger
)
assert capabilities == expected_capabilities
async def test_if_fires_not_on_above_below(hass, calls, caplog):
"""Test for value triggers firing."""
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
sensor1 = platform.ENTITIES["battery"]
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
"platform": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": sensor1.entity_id,
"type": "battery_level",
},
"action": {"service": "test.automation"},
}
]
},
)
assert "must contain at least one of below, above" in caplog.text
async def test_if_fires_on_state_above(hass, calls):
"""Test for value triggers firing."""
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
sensor1 = platform.ENTITIES["battery"]
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
"platform": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": sensor1.entity_id,
"type": "battery_level",
"above": 10,
},
"action": {
"service": "test.automation",
"data_template": {
"some": "bat_low {{ trigger.%s }}"
% "}} - {{ trigger.".join(
(
"platform",
"entity_id",
"from_state.state",
"to_state.state",
"for",
)
)
},
},
}
]
},
)
await hass.async_block_till_done()
assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN
assert len(calls) == 0
hass.states.async_set(sensor1.entity_id, 9)
await hass.async_block_till_done()
assert len(calls) == 0
hass.states.async_set(sensor1.entity_id, 11)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data["some"] == "bat_low device - {} - 9 - 11 - None".format(
sensor1.entity_id
)
async def test_if_fires_on_state_below(hass, calls):
"""Test for value triggers firing."""
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
sensor1 = platform.ENTITIES["battery"]
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
"platform": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": sensor1.entity_id,
"type": "battery_level",
"below": 10,
},
"action": {
"service": "test.automation",
"data_template": {
"some": "bat_low {{ trigger.%s }}"
% "}} - {{ trigger.".join(
(
"platform",
"entity_id",
"from_state.state",
"to_state.state",
"for",
)
)
},
},
}
]
},
)
await hass.async_block_till_done()
assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN
assert len(calls) == 0
hass.states.async_set(sensor1.entity_id, 11)
await hass.async_block_till_done()
assert len(calls) == 0
hass.states.async_set(sensor1.entity_id, 9)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data["some"] == "bat_low device - {} - 11 - 9 - None".format(
sensor1.entity_id
)
async def test_if_fires_on_state_between(hass, calls):
"""Test for value triggers firing."""
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
sensor1 = platform.ENTITIES["battery"]
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
"platform": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": sensor1.entity_id,
"type": "battery_level",
"above": 10,
"below": 20,
},
"action": {
"service": "test.automation",
"data_template": {
"some": "bat_low {{ trigger.%s }}"
% "}} - {{ trigger.".join(
(
"platform",
"entity_id",
"from_state.state",
"to_state.state",
"for",
)
)
},
},
}
]
},
)
await hass.async_block_till_done()
assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN
assert len(calls) == 0
hass.states.async_set(sensor1.entity_id, 9)
await hass.async_block_till_done()
assert len(calls) == 0
hass.states.async_set(sensor1.entity_id, 11)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data["some"] == "bat_low device - {} - 9 - 11 - None".format(
sensor1.entity_id
)
hass.states.async_set(sensor1.entity_id, 21)
await hass.async_block_till_done()
assert len(calls) == 1
hass.states.async_set(sensor1.entity_id, 19)
await hass.async_block_till_done()
assert len(calls) == 2
assert calls[1].data["some"] == "bat_low device - {} - 21 - 19 - None".format(
sensor1.entity_id
)
async def test_if_fires_on_state_change_with_for(hass, calls):
"""Test for triggers firing with delay."""
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
sensor1 = platform.ENTITIES["battery"]
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
"platform": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": sensor1.entity_id,
"type": "battery_level",
"above": 10,
"for": {"seconds": 5},
},
"action": {
"service": "test.automation",
"data_template": {
"some": "turn_off {{ trigger.%s }}"
% "}} - {{ trigger.".join(
(
"platform",
"entity_id",
"from_state.state",
"to_state.state",
"for",
)
)
},
},
}
]
},
)
await hass.async_block_till_done()
assert hass.states.get(sensor1.entity_id).state == STATE_UNKNOWN
assert len(calls) == 0
hass.states.async_set(sensor1.entity_id, 11)
await hass.async_block_till_done()
assert len(calls) == 0
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert len(calls) == 1
await hass.async_block_till_done()
assert calls[0].data[
"some"
] == "turn_off device - {} - unknown - 11 - 0:00:05".format(sensor1.entity_id)

View file

@ -13,7 +13,8 @@ from homeassistant.util import location
from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY
from homeassistant.auth.providers import legacy_api_password, homeassistant from homeassistant.auth.providers import legacy_api_password, homeassistant
from tests.common import ( pytest.register_assert_rewrite("tests.common")
from tests.common import ( # noqa: E402 module level import not at top of file
async_test_home_assistant, async_test_home_assistant,
INSTANCES, INSTANCES,
mock_coro, mock_coro,
@ -21,7 +22,9 @@ from tests.common import (
MockUser, MockUser,
CLIENT_ID, CLIENT_ID,
) )
from tests.test_util.aiohttp import mock_aiohttp_client from tests.test_util.aiohttp import (
mock_aiohttp_client,
) # noqa: E402 module level import not at top of file
if os.environ.get("UVLOOP") == "1": if os.environ.get("UVLOOP") == "1":
import uvloop import uvloop

View file

@ -0,0 +1,44 @@
"""
Provide a mock sensor platform.
Call init before using it in your tests to ensure clean test data.
"""
from homeassistant.components.sensor import DEVICE_CLASSES
from tests.common import MockEntity
ENTITIES = {}
def init(empty=False):
"""Initialize the platform with entities."""
global ENTITIES
ENTITIES = (
{}
if empty
else {
device_class: MockSensor(
name=f"{device_class} sensor",
unique_id=f"unique_{device_class}",
device_class=device_class,
)
for device_class in DEVICE_CLASSES
}
)
async def async_setup_platform(
hass, config, async_add_entities_callback, discovery_info=None
):
"""Return mock entities."""
async_add_entities_callback(list(ENTITIES.values()))
class MockSensor(MockEntity):
"""Mock Sensor class."""
@property
def device_class(self):
"""Return the class of this sensor."""
return self._handle("device_class")