Add Button entity component platform (#57642)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Franck Nijhof 2021-11-04 16:50:43 +01:00 committed by GitHub
parent 4c5aca93df
commit d126d88977
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 708 additions and 0 deletions

View File

@ -24,6 +24,7 @@ homeassistant.components.bmw_connected_drive.*
homeassistant.components.bond.*
homeassistant.components.braviatv.*
homeassistant.components.brother.*
homeassistant.components.button.*
homeassistant.components.calendar.*
homeassistant.components.camera.*
homeassistant.components.canary.*

View File

@ -85,6 +85,7 @@ homeassistant/components/brunt/* @eavanvalkenburg
homeassistant/components/bsblan/* @liudger
homeassistant/components/bt_smarthub/* @jxwolstenholme
homeassistant/components/buienradar/* @mjj4791 @ties @Robbie1221
homeassistant/components/button/* @home-assistant/core
homeassistant/components/cast/* @emontnemery
homeassistant/components/cert_expiry/* @Cereal2nd @jjlawren
homeassistant/components/circuit/* @braam

View File

@ -0,0 +1,104 @@
"""Component to pressing a button as platforms."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import final
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
)
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import DOMAIN, SERVICE_PRESS
SCAN_INTERVAL = timedelta(seconds=30)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Button entities."""
component = hass.data[DOMAIN] = EntityComponent(
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)
component.async_register_entity_service(
SERVICE_PRESS,
{},
"_async_press_action",
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
component: EntityComponent = hass.data[DOMAIN]
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
component: EntityComponent = hass.data[DOMAIN]
return await component.async_unload_entry(entry)
@dataclass
class ButtonEntityDescription(EntityDescription):
"""A class that describes button entities."""
class ButtonEntity(RestoreEntity):
"""Representation of a Button entity."""
entity_description: ButtonEntityDescription
_attr_should_poll = False
_attr_device_class: None = None
_attr_state: None = None
__last_pressed: datetime | None = None
@property
@final
def state(self) -> str | None:
"""Return the entity state."""
if self.__last_pressed is None:
return None
return self.__last_pressed.isoformat()
@final
async def _async_press_action(self) -> None:
"""Press the button (from e.g., service call).
Should not be overridden, handle setting last press timestamp.
"""
self.__last_pressed = dt_util.utcnow()
self.async_write_ha_state()
await self.async_press()
async def async_added_to_hass(self) -> None:
"""Call when the button is added to hass."""
state = await self.async_get_last_state()
if state is not None and state.state is not None:
self.__last_pressed = dt_util.parse_datetime(state.state)
def press(self) -> None:
"""Press the button."""
raise NotImplementedError()
async def async_press(self) -> None:
"""Press the button."""
await self.hass.async_add_executor_job(self.press)

View File

@ -0,0 +1,4 @@
"""Provides the constants needed for the component."""
DOMAIN = "button"
SERVICE_PRESS = "press"

View File

@ -0,0 +1,58 @@
"""Provides device actions for Button."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_DEVICE_ID,
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_TYPE,
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import entity_registry
import homeassistant.helpers.config_validation as cv
from .const import DOMAIN, SERVICE_PRESS
ACTION_TYPES = {"press"}
ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): vol.In(ACTION_TYPES),
vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN),
}
)
async def async_get_actions(
hass: HomeAssistant, device_id: str
) -> list[dict[str, str]]:
"""List device actions for button devices."""
registry = entity_registry.async_get(hass)
return [
{
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
CONF_ENTITY_ID: entry.entity_id,
CONF_TYPE: "press",
}
for entry in entity_registry.async_entries_for_device(registry, device_id)
if entry.domain == DOMAIN
]
async def async_call_action_from_config(
hass: HomeAssistant, config: dict, variables: dict, context: Context | None
) -> None:
"""Execute a device action."""
await hass.services.async_call(
DOMAIN,
SERVICE_PRESS,
{
ATTR_ENTITY_ID: config[CONF_ENTITY_ID],
},
blocking=True,
context=context,
)

View File

@ -0,0 +1,73 @@
"""Provides device triggers for Button."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.automation import (
AutomationActionType,
AutomationTriggerInfo,
)
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
from homeassistant.components.homeassistant.triggers.state import (
TRIGGER_SCHEMA as STATE_TRIGGER_SCHEMA,
async_attach_trigger as async_attach_state_trigger,
)
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_PLATFORM,
CONF_TYPE,
)
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 = {"pressed"}
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES),
}
)
async def async_get_triggers(
hass: HomeAssistant, device_id: str
) -> list[dict[str, Any]]:
"""List device triggers for button devices."""
registry = entity_registry.async_get(hass)
return [
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
CONF_ENTITY_ID: entry.entity_id,
CONF_TYPE: "pressed",
}
for entry in entity_registry.async_entries_for_device(registry, device_id)
if entry.domain == DOMAIN
]
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: AutomationActionType,
automation_info: AutomationTriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
state_config = {
CONF_PLATFORM: "state",
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
}
state_config = STATE_TRIGGER_SCHEMA(state_config)
return await async_attach_state_trigger(
hass, state_config, action, automation_info, platform_type="device"
)

View File

@ -0,0 +1,7 @@
{
"domain": "button",
"name": "Button",
"documentation": "https://www.home-assistant.io/integrations/button",
"codeowners": ["@home-assistant/core"],
"quality_scale": "internal"
}

View File

@ -0,0 +1,6 @@
press:
name: Press
description: Press the button entity.
target:
entity:
domain: button

View File

@ -0,0 +1,11 @@
{
"title": "Button",
"device_automation": {
"trigger_type": {
"pressed": "{entity_name} has been pressed"
},
"action_type": {
"press": "Press {entity_name} button"
}
}
}

View File

@ -0,0 +1,11 @@
{
"device_automation": {
"action_type": {
"press": "Press {entity_name} button"
},
"trigger_type": {
"pressed": "{entity_name} has been pressed"
}
},
"title": "Button"
}

View File

@ -15,6 +15,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
"air_quality",
"alarm_control_panel",
"binary_sensor",
"button",
"camera",
"climate",
"cover",

View File

@ -0,0 +1,65 @@
"""Demo platform that offers a fake button entity."""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DEVICE_DEFAULT_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType = None,
) -> None:
"""Set up the demo Button entity."""
async_add_entities(
[
DemoButton(
unique_id="push",
name="Push",
icon="mdi:gesture-tap-button",
),
]
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
await async_setup_platform(hass, {}, async_add_entities)
class DemoButton(ButtonEntity):
"""Representation of a demo button entity."""
_attr_should_poll = False
def __init__(
self,
unique_id: str,
name: str,
icon: str,
) -> None:
"""Initialize the Demo button entity."""
self._attr_unique_id = unique_id
self._attr_name = name or DEVICE_DEFAULT_NAME
self._attr_icon = icon
self._attr_device_info = {
"identifiers": {(DOMAIN, unique_id)},
"name": name,
}
async def async_press(self) -> None:
"""Send out a persistent notification."""
self.hass.components.persistent_notification.async_create(
"Button pressed", title="Button"
)

View File

@ -275,6 +275,17 @@ no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.button.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.calendar.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -38,6 +38,7 @@ NO_IOT_CLASS = [
"automation",
"binary_sensor",
"blueprint",
"button",
"calendar",
"camera",
"climate",

View File

@ -0,0 +1 @@
"""The tests for the Button integration."""

View File

@ -0,0 +1,87 @@
"""The tests for Button device actions."""
import pytest
from homeassistant.components import automation
from homeassistant.components.button import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry, entity_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,
)
@pytest.fixture
def device_reg(hass: HomeAssistant) -> device_registry.DeviceRegistry:
"""Return an empty, loaded, registry."""
return mock_device_registry(hass)
@pytest.fixture
def entity_reg(hass: HomeAssistant) -> entity_registry.EntityRegistry:
"""Return an empty, loaded, registry."""
return mock_registry(hass)
async def test_get_actions(
hass: HomeAssistant,
device_reg: device_registry.DeviceRegistry,
entity_reg: entity_registry.EntityRegistry,
) -> None:
"""Test we get the expected actions from a button."""
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_actions = [
{
"domain": DOMAIN,
"type": "press",
"device_id": device_entry.id,
"entity_id": "button.test_5678",
}
]
actions = await async_get_device_automations(hass, "action", device_entry.id)
assert_lists_same(actions, expected_actions)
async def test_action(hass: HomeAssistant) -> None:
"""Test for press action."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
"platform": "event",
"event_type": "test_event",
},
"action": {
"domain": DOMAIN,
"device_id": "abcdefgh",
"entity_id": "button.entity",
"type": "press",
},
},
]
},
)
press_calls = async_mock_service(hass, DOMAIN, "press")
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(press_calls) == 1
assert press_calls[0].domain == DOMAIN
assert press_calls[0].service == "press"
assert press_calls[0].data == {"entity_id": "button.entity"}

View File

@ -0,0 +1,108 @@
"""The tests for Button device triggers."""
from __future__ import annotations
import pytest
from homeassistant.components import automation
from homeassistant.components.button import DOMAIN
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry
from homeassistant.helpers.entity_registry import EntityRegistry
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,
)
@pytest.fixture
def device_reg(hass: HomeAssistant) -> device_registry.DeviceRegistry:
"""Return an empty, loaded, registry."""
return mock_device_registry(hass)
@pytest.fixture
def entity_reg(hass: HomeAssistant) -> EntityRegistry:
"""Return an empty, loaded, registry."""
return mock_registry(hass)
@pytest.fixture
def calls(hass: HomeAssistant) -> list[ServiceCall]:
"""Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
async def test_get_triggers(
hass: HomeAssistant,
device_reg: device_registry.DeviceRegistry,
entity_reg: EntityRegistry,
) -> None:
"""Test we get the expected triggers from a button."""
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": "pressed",
"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_state_change(hass, calls):
"""Test for turn_on and turn_off triggers firing."""
hass.states.async_set("button.entity", "unknown")
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
"platform": "device",
"domain": DOMAIN,
"device_id": "",
"entity_id": "button.entity",
"type": "pressed",
},
"action": {
"service": "test.automation",
"data": {
"some": (
"to - {{ trigger.platform}} - "
"{{ trigger.entity_id}} - {{ trigger.from_state.state}} - "
"{{ trigger.to_state.state}} - {{ trigger.for }} - "
"{{ trigger.id}}"
)
},
},
}
]
},
)
# Test triggering device trigger with a to state
hass.states.async_set("button.entity", "2021-01-01T23:59:59+00:00")
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data[
"some"
] == "to - device - {} - unknown - 2021-01-01T23:59:59+00:00 - None - 0".format(
"button.entity"
)

View File

@ -0,0 +1,64 @@
"""The tests for the Button component."""
from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components.button import DOMAIN, SERVICE_PRESS, ButtonEntity
from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import mock_restore_cache
async def test_button(hass: HomeAssistant) -> None:
"""Test getting data from the mocked button entity."""
button = ButtonEntity()
assert button.state is None
button.hass = hass
with pytest.raises(NotImplementedError):
await button.async_press()
button.press = MagicMock()
await button.async_press()
assert button.press.called
async def test_custom_integration(hass, caplog, enable_custom_integrations):
"""Test we integration."""
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
await hass.async_block_till_done()
assert hass.states.get("button.button_1").state == STATE_UNKNOWN
now = dt_util.utcnow()
with patch("homeassistant.core.dt_util.utcnow", return_value=now):
await hass.services.async_call(
DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: "button.button_1"},
blocking=True,
)
assert hass.states.get("button.button_1").state == now.isoformat()
assert "The button has been pressed" in caplog.text
async def test_restore_state(hass, enable_custom_integrations):
"""Test we restore state integration."""
mock_restore_cache(hass, (State("button.button_1", "2021-01-01T23:59:59+00:00"),))
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
await hass.async_block_till_done()
assert hass.states.get("button.button_1").state == "2021-01-01T23:59:59+00:00"

View File

@ -0,0 +1,47 @@
"""The tests for the demo button component."""
from unittest.mock import patch
import pytest
from homeassistant.components.button.const import DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
ENTITY_PUSH = "button.push"
@pytest.fixture(autouse=True)
async def setup_demo_button(hass: HomeAssistant) -> None:
"""Initialize setup demo button entity."""
assert await async_setup_component(hass, DOMAIN, {"button": {"platform": "demo"}})
await hass.async_block_till_done()
def test_setup_params(hass: HomeAssistant) -> None:
"""Test the initial parameters."""
state = hass.states.get(ENTITY_PUSH)
assert state
assert state.state == STATE_UNKNOWN
async def test_press(hass: HomeAssistant) -> None:
"""Test pressing the button."""
state = hass.states.get(ENTITY_PUSH)
assert state
assert state.state == STATE_UNKNOWN
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
with patch("homeassistant.util.dt.utcnow", return_value=now):
await hass.services.async_call(
DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: ENTITY_PUSH},
blocking=True,
)
state = hass.states.get(ENTITY_PUSH)
assert state
assert state.state == now.isoformat()

View File

@ -0,0 +1,47 @@
"""
Provide a mock button platform.
Call init before using it in your tests to ensure clean test data.
"""
import logging
from homeassistant.components.button import ButtonEntity
from tests.common import MockEntity
UNIQUE_BUTTON_1 = "unique_button_1"
ENTITIES = []
_LOGGER = logging.getLogger(__name__)
class MockButtonEntity(MockEntity, ButtonEntity):
"""Mock Button class."""
def press(self) -> None:
"""Press the button."""
_LOGGER.info("The button has been pressed")
def init(empty=False):
"""Initialize the platform with entities."""
global ENTITIES
ENTITIES = (
[]
if empty
else [
MockButtonEntity(
name="button 1",
unique_id="unique_button_1",
),
]
)
async def async_setup_platform(
hass, config, async_add_entities_callback, discovery_info=None
):
"""Return mock entities."""
async_add_entities_callback(ENTITIES)