diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 8369892be85..228803097d6 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -27,10 +27,12 @@ DOMAIN = "kitchen_sink" COMPONENTS_WITH_DEMO_PLATFORM = [ + Platform.BUTTON, Platform.IMAGE, Platform.LAWN_MOWER, Platform.LOCK, Platform.SENSOR, + Platform.SWITCH, Platform.WEATHER, ] diff --git a/homeassistant/components/kitchen_sink/button.py b/homeassistant/components/kitchen_sink/button.py new file mode 100644 index 00000000000..1a8da80983f --- /dev/null +++ b/homeassistant/components/kitchen_sink/button.py @@ -0,0 +1,56 @@ +"""Demo platform that offers a fake button entity.""" +from __future__ import annotations + +from homeassistant.components import persistent_notification +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo button platform.""" + async_add_entities( + [ + DemoButton( + unique_id="2_ch_power_strip", + device_name="2CH Power strip", + entity_name="Restart", + ), + ] + ) + + +class DemoButton(ButtonEntity): + """Representation of a demo button entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + device_name: str, + entity_name: str | None, + ) -> None: + """Initialize the Demo button entity.""" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + self._attr_name = entity_name + + async def async_press(self) -> None: + """Send out a persistent notification.""" + persistent_notification.async_create( + self.hass, "Button pressed", title="Button" + ) + self.hass.bus.async_fire("demo_button_pressed") diff --git a/homeassistant/components/kitchen_sink/device.py b/homeassistant/components/kitchen_sink/device.py new file mode 100644 index 00000000000..295e7869ec4 --- /dev/null +++ b/homeassistant/components/kitchen_sink/device.py @@ -0,0 +1,23 @@ +"""Create device without entities.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import DOMAIN + + +def async_create_device( + hass: HomeAssistant, + config_entry_id: str, + device_name: str | None, + unique_id: str, +) -> dr.DeviceEntry: + """Create a device.""" + device_registry = dr.async_get(hass) + return device_registry.async_get_or_create( + config_entry_id=config_entry_id, + name=device_name, + identifiers={(DOMAIN, unique_id)}, + ) diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 4e1e3bd2010..a14c4a26e4e 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -11,9 +11,10 @@ from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType from . import DOMAIN +from .device import async_create_device async def async_setup_entry( @@ -22,31 +23,63 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Everything but the Kitchen Sink config entry.""" + async_create_device( + hass, config_entry.entry_id, "2CH Power strip", "2_ch_power_strip" + ) + async_add_entities( [ DemoSensor( - "statistics_issue_1", - "Statistics issue 1", - 100, - None, - SensorStateClass.MEASUREMENT, - UnitOfPower.WATT, # Not a volume unit + device_unique_id="outlet_1", + unique_id="outlet_1_power", + device_name="Outlet 1", + entity_name=UNDEFINED, + state=50, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=UnitOfPower.WATT, + via_device="2_ch_power_strip", ), DemoSensor( - "statistics_issue_2", - "Statistics issue 2", - 100, - None, - SensorStateClass.MEASUREMENT, - "dogs", # Can't be converted to cats + device_unique_id="outlet_2", + unique_id="outlet_2_power", + device_name="Outlet 2", + entity_name=UNDEFINED, + state=1500, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=UnitOfPower.WATT, + via_device="2_ch_power_strip", ), DemoSensor( - "statistics_issue_3", - "Statistics issue 3", - 100, - None, - None, # Wrong state class - UnitOfPower.WATT, + device_unique_id="statistics_issues", + unique_id="statistics_issue_1", + device_name="Statistics issues", + entity_name="Issue 1", + state=100, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=UnitOfPower.WATT, + ), + DemoSensor( + device_unique_id="statistics_issues", + unique_id="statistics_issue_2", + device_name="Statistics issues", + entity_name="Issue 2", + state=100, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement="dogs", + ), + DemoSensor( + device_unique_id="statistics_issues", + unique_id="statistics_issue_3", + device_name="Statistics issues", + entity_name="Issue 3", + state=100, + device_class=None, + state_class=None, + unit_of_measurement=UnitOfPower.WATT, ), ] ) @@ -55,26 +88,34 @@ async def async_setup_entry( class DemoSensor(SensorEntity): """Representation of a Demo sensor.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, + *, + device_unique_id: str, unique_id: str, - name: str, + device_name: str, + entity_name: str | None | UndefinedType, state: StateType, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit_of_measurement: str | None, + via_device: str | None = None, ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class - self._attr_name = name + if entity_name is not UNDEFINED: + self._attr_name = entity_name self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state self._attr_state_class = state_class self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name=name, + identifiers={(DOMAIN, device_unique_id)}, + name=device_name, ) + if via_device: + self._attr_device_info["via_device"] = (DOMAIN, via_device) diff --git a/homeassistant/components/kitchen_sink/switch.py b/homeassistant/components/kitchen_sink/switch.py new file mode 100644 index 00000000000..4329be8b9d7 --- /dev/null +++ b/homeassistant/components/kitchen_sink/switch.py @@ -0,0 +1,88 @@ +"""Demo platform that has some fake switches.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN +from .device import async_create_device + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo switch platform.""" + async_create_device( + hass, config_entry.entry_id, "2CH Power strip", "2_ch_power_strip" + ) + + async_add_entities( + [ + DemoSwitch( + unique_id="outlet_1", + device_name="Outlet 1", + entity_name=None, + state=False, + assumed=False, + via_device="2_ch_power_strip", + ), + DemoSwitch( + unique_id="outlet_2", + device_name="Outlet 2", + entity_name=None, + state=True, + assumed=False, + via_device="2_ch_power_strip", + ), + ] + ) + + +class DemoSwitch(SwitchEntity): + """Representation of a demo switch.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + *, + unique_id: str, + device_name: str, + entity_name: str | None, + state: bool, + assumed: bool, + translation_key: str | None = None, + device_class: SwitchDeviceClass | None = None, + via_device: str | None = None, + ) -> None: + """Initialize the Demo switch.""" + self._attr_assumed_state = assumed + self._attr_device_class = device_class + self._attr_translation_key = translation_key + self._attr_is_on = state + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + if via_device: + self._attr_device_info["via_device"] = (DOMAIN, via_device) + self._attr_name = entity_name + + def turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._attr_is_on = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self._attr_is_on = False + self.schedule_update_ha_state() diff --git a/tests/components/kitchen_sink/snapshots/test_sensor.ambr b/tests/components/kitchen_sink/snapshots/test_sensor.ambr index de3297b7fd8..776a0c03369 100644 --- a/tests/components/kitchen_sink/snapshots/test_sensor.ambr +++ b/tests/components/kitchen_sink/snapshots/test_sensor.ambr @@ -3,35 +3,61 @@ set({ StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Statistics issue 1', + 'device_class': 'power', + 'friendly_name': 'Outlet 1 Power', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.statistics_issue_1', + 'entity_id': 'sensor.outlet_1_power', + 'last_changed': , + 'last_updated': , + 'state': '50', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Outlet 2 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_2_power', + 'last_changed': , + 'last_updated': , + 'state': '1500', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Statistics issues Issue 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_1', 'last_changed': , 'last_updated': , 'state': '100', }), StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Statistics issue 2', + 'friendly_name': 'Statistics issues Issue 2', 'state_class': , 'unit_of_measurement': 'dogs', }), 'context': , - 'entity_id': 'sensor.statistics_issue_2', + 'entity_id': 'sensor.statistics_issues_issue_2', 'last_changed': , 'last_updated': , 'state': '100', }), StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Statistics issue 3', + 'friendly_name': 'Statistics issues Issue 3', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.statistics_issue_3', + 'entity_id': 'sensor.statistics_issues_issue_3', 'last_changed': , 'last_updated': , 'state': '100', diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr new file mode 100644 index 00000000000..099309c8f88 --- /dev/null +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -0,0 +1,199 @@ +# serializer version: 1 +# name: test_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Outlet 1', + }), + 'context': , + 'entity_id': 'switch.outlet_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_state.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.outlet_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'kitchen_sink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'outlet_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_state.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'kitchen_sink', + 'outlet_1', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'Outlet 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_state.3 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'kitchen_sink', + '2_ch_power_strip', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': '2CH Power strip', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_state.4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Outlet 2', + }), + 'context': , + 'entity_id': 'switch.outlet_2', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_state.5 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.outlet_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'kitchen_sink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'outlet_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_state.6 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'kitchen_sink', + 'outlet_2', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'Outlet 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_state.7 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'kitchen_sink', + '2_ch_power_strip', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': '2CH Power strip', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/kitchen_sink/test_button.py b/tests/components/kitchen_sink/test_button.py new file mode 100644 index 00000000000..3f49f814c92 --- /dev/null +++ b/tests/components/kitchen_sink/test_button.py @@ -0,0 +1,59 @@ +"""The tests for the demo button component.""" + +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +ENTITY_RESTART = "button.2ch_power_strip_restart" + + +@pytest.fixture +async def button_only() -> None: + """Enable only the button platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.BUTTON], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, button_only): + """Set up demo component.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get(ENTITY_RESTART) + assert state + assert state.state == STATE_UNKNOWN + + +async def test_press(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Test pressing the button.""" + state = hass.states.get(ENTITY_RESTART) + assert state + assert state.state == STATE_UNKNOWN + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_RESTART}, + blocking=True, + ) + + state = hass.states.get(ENTITY_RESTART) + assert state + assert state.state == now.isoformat() diff --git a/tests/components/kitchen_sink/test_switch.py b/tests/components/kitchen_sink/test_switch.py new file mode 100644 index 00000000000..c744ba2be44 --- /dev/null +++ b/tests/components/kitchen_sink/test_switch.py @@ -0,0 +1,102 @@ +"""The tests for the demo switch component.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +SWITCH_ENTITY_IDS = ["switch.outlet_1", "switch.outlet_2"] + + +@pytest.fixture +async def switch_only() -> None: + """Enable only the switch platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.SWITCH], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass, switch_only): + """Set up demo component.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +async def test_state( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test switch state.""" + for entity_id in SWITCH_ENTITY_IDS: + state = hass.states.get(entity_id) + assert state == snapshot + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry == snapshot + sub_device_entry = device_registry.async_get(entity_entry.device_id) + assert sub_device_entry == snapshot + main_device_entry = device_registry.async_get(sub_device_entry.via_device_id) + assert main_device_entry == snapshot + + +@pytest.mark.parametrize("switch_entity_id", SWITCH_ENTITY_IDS) +async def test_turn_on(hass: HomeAssistant, switch_entity_id) -> None: + """Test switch turn on method.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: switch_entity_id}, + blocking=True, + ) + + state = hass.states.get(switch_entity_id) + assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch_entity_id}, + blocking=True, + ) + + state = hass.states.get(switch_entity_id) + assert state.state == STATE_ON + + +@pytest.mark.parametrize("switch_entity_id", SWITCH_ENTITY_IDS) +async def test_turn_off(hass: HomeAssistant, switch_entity_id) -> None: + """Test switch turn off method.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch_entity_id}, + blocking=True, + ) + + state = hass.states.get(switch_entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: switch_entity_id}, + blocking=True, + ) + + state = hass.states.get(switch_entity_id) + assert state.state == STATE_OFF