Add device trigger support for device trackers (#42324)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Matthew Donoughe 2020-11-09 06:04:16 -05:00 committed by GitHub
parent 5f09addb74
commit bc4bbaf6ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 326 additions and 5 deletions

View File

@ -0,0 +1,105 @@
"""Provides device automations for Device Tracker."""
from typing import List
import voluptuous as vol
from homeassistant.components.automation import AutomationActionType
from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA
from homeassistant.components.zone import DOMAIN as DOMAIN_ZONE, trigger as zone
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_PLATFORM,
CONF_TYPE,
CONF_ZONE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry
from homeassistant.helpers.typing import ConfigType
from . import DOMAIN
TRIGGER_TYPES = {"enters", "leaves"}
TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES),
vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN_ZONE),
}
)
async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]:
"""List device triggers for Device Tracker devices."""
registry = await entity_registry.async_get_registry(hass)
triggers = []
# Get all the integrations entities for this device
for entry in entity_registry.async_entries_for_device(registry, device_id):
if entry.domain != DOMAIN:
continue
triggers.append(
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
CONF_ENTITY_ID: entry.entity_id,
CONF_TYPE: "enters",
}
)
triggers.append(
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
CONF_ENTITY_ID: entry.entity_id,
CONF_TYPE: "leaves",
}
)
return triggers
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: AutomationActionType,
automation_info: dict,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
config = TRIGGER_SCHEMA(config)
if config[CONF_TYPE] == "enters":
event = zone.EVENT_ENTER
else:
event = zone.EVENT_LEAVE
zone_config = {
CONF_PLATFORM: DOMAIN_ZONE,
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
CONF_ZONE: config[CONF_ZONE],
CONF_EVENT: event,
}
zone_config = zone.TRIGGER_SCHEMA(zone_config)
return await zone.async_attach_trigger(
hass, zone_config, action, automation_info, platform_type="device"
)
async def async_get_trigger_capabilities(hass: HomeAssistant, config: ConfigType):
"""List trigger capabilities."""
zones = {
ent.entity_id: ent.name
for ent in sorted(hass.states.async_all(DOMAIN_ZONE), key=lambda ent: ent.name)
}
return {
"extra_fields": vol.Schema(
{
vol.Required(CONF_ZONE): vol.In(zones),
}
)
}

View File

@ -4,6 +4,10 @@
"condition_type": {
"is_home": "{entity_name} is home",
"is_not_home": "{entity_name} is not home"
},
"trigger_type": {
"enters": "{entity_name} enters a zone",
"leaves": "{entity_name} leaves a zone"
}
},
"state": {
@ -12,4 +16,4 @@
"not_home": "[%key:common::state::not_home%]"
}
}
}
}

View File

@ -3,6 +3,10 @@
"condition_type": {
"is_home": "{entity_name} is home",
"is_not_home": "{entity_name} is not home"
},
"trigger_type": {
"enters": "{entity_name} enters a zone",
"leaves": "{entity_name} leaves a zone"
}
},
"state": {

View File

@ -8,11 +8,12 @@ from homeassistant.const import (
CONF_PLATFORM,
CONF_ZONE,
)
from homeassistant.core import HassJob, callback
from homeassistant.core import CALLBACK_TYPE, HassJob, callback
from homeassistant.helpers import condition, config_validation as cv, location
from homeassistant.helpers.event import async_track_state_change_event
# mypy: allow-untyped-defs, no-check-untyped-defs
# mypy: allow-incomplete-defs, allow-untyped-defs
# mypy: no-check-untyped-defs
EVENT_ENTER = "enter"
EVENT_LEAVE = "leave"
@ -32,7 +33,9 @@ TRIGGER_SCHEMA = vol.Schema(
)
async def async_attach_trigger(hass, config, action, automation_info):
async def async_attach_trigger(
hass, config, action, automation_info, *, platform_type: str = "zone"
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
entity_id = config.get(CONF_ENTITY_ID)
zone_entity_id = config.get(CONF_ZONE)
@ -70,7 +73,7 @@ async def async_attach_trigger(hass, config, action, automation_info):
job,
{
"trigger": {
"platform": "zone",
"platform": platform_type,
"entity_id": entity,
"from_state": from_s,
"to_state": to_s,

View File

@ -0,0 +1,205 @@
"""The tests for Device Tracker device triggers."""
import pytest
import voluptuous_serialize
import homeassistant.components.automation as automation
from homeassistant.components.device_tracker import DOMAIN, device_trigger
import homeassistant.components.zone as zone
from homeassistant.helpers import config_validation as cv, device_registry
from homeassistant.setup import async_setup_component
from tests.common import (
MockConfigEntry,
assert_lists_same,
async_get_device_automations,
async_mock_service,
mock_device_registry,
mock_registry,
)
AWAY_LATITUDE = 32.881011
AWAY_LONGITUDE = -117.234758
HOME_LATITUDE = 32.880837
HOME_LONGITUDE = -117.237561
@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 service."""
return async_mock_service(hass, "test", "automation")
@pytest.fixture(autouse=True)
def setup_zone(hass):
"""Create test zone."""
hass.loop.run_until_complete(
async_setup_component(
hass,
zone.DOMAIN,
{
"zone": {
"name": "test",
"latitude": HOME_LATITUDE,
"longitude": HOME_LONGITUDE,
"radius": 250,
}
},
)
)
async def test_get_triggers(hass, device_reg, entity_reg):
"""Test we get the expected triggers from a device_tracker."""
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_triggers = [
{
"platform": "device",
"domain": DOMAIN,
"type": "leaves",
"device_id": device_entry.id,
"entity_id": f"{DOMAIN}.test_5678",
},
{
"platform": "device",
"domain": DOMAIN,
"type": "enters",
"device_id": device_entry.id,
"entity_id": f"{DOMAIN}.test_5678",
},
]
triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
assert_lists_same(triggers, expected_triggers)
async def test_if_fires_on_zone_change(hass, calls):
"""Test for enter and leave triggers firing."""
hass.states.async_set(
"device_tracker.entity",
"state",
{"latitude": AWAY_LATITUDE, "longitude": AWAY_LONGITUDE},
)
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
"platform": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": "device_tracker.entity",
"type": "enters",
"zone": "zone.test",
},
"action": {
"service": "test.automation",
"data_template": {
"some": (
"enter - {{ trigger.platform}} - "
"{{ trigger.entity_id}} - {{ trigger.from_state.attributes.longitude|round(3)}} - "
"{{ trigger.to_state.attributes.longitude|round(3)}}"
)
},
},
},
{
"trigger": {
"platform": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": "device_tracker.entity",
"type": "leaves",
"zone": "zone.test",
},
"action": {
"service": "test.automation",
"data_template": {
"some": (
"leave - {{ trigger.platform}} - "
"{{ trigger.entity_id}} - {{ trigger.from_state.attributes.longitude|round(3)}} - "
"{{ trigger.to_state.attributes.longitude|round(3)}}"
)
},
},
},
]
},
)
# Fake that the entity is entering.
hass.states.async_set(
"device_tracker.entity",
"state",
{"latitude": HOME_LATITUDE, "longitude": HOME_LONGITUDE},
)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data["some"] == "enter - device - {} - -117.235 - -117.238".format(
"device_tracker.entity"
)
# Fake that the entity is leaving.
hass.states.async_set(
"device_tracker.entity",
"state",
{"latitude": AWAY_LATITUDE, "longitude": AWAY_LONGITUDE},
)
await hass.async_block_till_done()
assert len(calls) == 2
assert calls[1].data["some"] == "leave - device - {} - -117.238 - -117.235".format(
"device_tracker.entity"
)
async def test_get_trigger_capabilities(hass, device_reg, entity_reg):
"""Test we get the expected capabilities from a device_tracker 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)
capabilities = await device_trigger.async_get_trigger_capabilities(
hass,
{
"platform": "device",
"domain": DOMAIN,
"type": "enters",
"device_id": device_entry.id,
"entity_id": f"{DOMAIN}.test_5678",
},
)
assert capabilities and "extra_fields" in capabilities
assert voluptuous_serialize.convert(
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
) == [
{
"name": "zone",
"required": True,
"type": "select",
"options": [("zone.test", "test"), ("zone.home", "test home")],
}
]