diff --git a/.coveragerc b/.coveragerc index a595efd4bcc..c3fd8ba71ff 100644 --- a/.coveragerc +++ b/.coveragerc @@ -546,6 +546,7 @@ omit = homeassistant/components/homematic/sensor.py homeassistant/components/homematic/switch.py homeassistant/components/homeworks/__init__.py + homeassistant/components/homeworks/binary_sensor.py homeassistant/components/homeworks/button.py homeassistant/components/homeworks/light.py homeassistant/components/horizon/media_player.py diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index 3ab833e2b08..83ae12dffba 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -21,6 +21,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType @@ -37,13 +38,14 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.LIGHT] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT] EVENT_BUTTON_PRESS = "homeworks_button_press" EVENT_BUTTON_RELEASE = "homeworks_button_release" DEFAULT_FADE_RATE = 1.0 +KEYPAD_LEDSTATE_POLL_COOLDOWN = 1.0 CV_FADE_RATE = vol.All(vol.Coerce(float), vol.Range(min=0, max=20)) @@ -208,6 +210,13 @@ class HomeworksKeypad: """Register callback that will be used for signals.""" self._addr = addr self._controller = controller + self._debouncer = Debouncer( + hass, + _LOGGER, + cooldown=KEYPAD_LEDSTATE_POLL_COOLDOWN, + immediate=False, + function=self._request_keypad_led_states, + ) self._hass = hass self._name = name self._id = slugify(self._name) @@ -229,3 +238,15 @@ class HomeworksKeypad: return data = {CONF_ID: self._id, CONF_NAME: self._name, "button": values[1]} self._hass.bus.async_fire(event, data) + + def _request_keypad_led_states(self) -> None: + """Query keypad led state.""" + # pylint: disable-next=protected-access + self._controller._send(f"RKLS, {self._addr}") + + async def request_keypad_led_states(self) -> None: + """Query keypad led state. + + Debounced to not storm the controller during setup. + """ + await self._debouncer.async_call() diff --git a/homeassistant/components/homeworks/binary_sensor.py b/homeassistant/components/homeworks/binary_sensor.py new file mode 100644 index 00000000000..a4d315b6821 --- /dev/null +++ b/homeassistant/components/homeworks/binary_sensor.py @@ -0,0 +1,93 @@ +"""Support for Lutron Homeworks binary sensors.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyhomeworks.pyhomeworks import HW_KEYPAD_LED_CHANGED, Homeworks + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeworksData, HomeworksEntity, HomeworksKeypad +from .const import ( + CONF_ADDR, + CONF_BUTTONS, + CONF_CONTROLLER_ID, + CONF_KEYPADS, + CONF_LED, + CONF_NUMBER, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Homeworks binary sensors.""" + data: HomeworksData = hass.data[DOMAIN][entry.entry_id] + controller = data.controller + controller_id = entry.options[CONF_CONTROLLER_ID] + devs = [] + for keypad in entry.options.get(CONF_KEYPADS, []): + for button in keypad[CONF_BUTTONS]: + if not button[CONF_LED]: + continue + dev = HomeworksBinarySensor( + controller, + data.keypads[keypad[CONF_ADDR]], + controller_id, + keypad[CONF_ADDR], + keypad[CONF_NAME], + button[CONF_NAME], + button[CONF_NUMBER], + ) + devs.append(dev) + async_add_entities(devs, True) + + +class HomeworksBinarySensor(HomeworksEntity, BinarySensorEntity): + """Homeworks Binary Sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + controller: Homeworks, + keypad: HomeworksKeypad, + controller_id: str, + addr: str, + keypad_name: str, + button_name: str, + led_number: int, + ) -> None: + """Create device with Addr, name, and rate.""" + super().__init__(controller, controller_id, addr, led_number, button_name) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{controller_id}.{addr}")}, name=keypad_name + ) + self._keypad = keypad + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + signal = f"homeworks_entity_{self._controller_id}_{self._addr}" + _LOGGER.debug("connecting %s", signal) + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self._update_callback) + ) + await self._keypad.request_keypad_led_states() + + @callback + def _update_callback(self, msg_type: str, values: list[Any]) -> None: + """Process device specific messages.""" + if msg_type != HW_KEYPAD_LED_CHANGED or len(values[1]) < self._idx: + return + self._attr_is_on = bool(values[1][self._idx - 1]) + self.async_write_ha_state() diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py index ec65cef970c..b2fe4e0e022 100644 --- a/homeassistant/components/homeworks/config_flow.py +++ b/homeassistant/components/homeworks/config_flow.py @@ -9,6 +9,7 @@ from typing import Any from pyhomeworks.pyhomeworks import Homeworks import voluptuous as vol +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult @@ -44,6 +45,7 @@ from .const import ( CONF_DIMMERS, CONF_INDEX, CONF_KEYPADS, + CONF_LED, CONF_NUMBER, CONF_RATE, CONF_RELEASE_DELAY, @@ -78,6 +80,7 @@ LIGHT_EDIT = { } BUTTON_EDIT = { + vol.Optional(CONF_LED, default=False): selector.BooleanSelector(), vol.Optional(CONF_RELEASE_DELAY, default=0): selector.NumberSelector( selector.NumberSelectorConfig( min=0, @@ -380,16 +383,17 @@ async def validate_remove_button( if str(index) not in removed_indexes: items.append(item) button_number = keypad[CONF_BUTTONS][index][CONF_NUMBER] - if entity_id := entity_registry.async_get_entity_id( - BUTTON_DOMAIN, - DOMAIN, - calculate_unique_id( - handler.options[CONF_CONTROLLER_ID], - keypad[CONF_ADDR], - button_number, - ), - ): - entity_registry.async_remove(entity_id) + for domain in (BINARY_SENSOR_DOMAIN, BUTTON_DOMAIN): + if entity_id := entity_registry.async_get_entity_id( + domain, + DOMAIN, + calculate_unique_id( + handler.options[CONF_CONTROLLER_ID], + keypad[CONF_ADDR], + button_number, + ), + ): + entity_registry.async_remove(entity_id) keypad[CONF_BUTTONS] = items return {} @@ -563,6 +567,7 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN): CONF_ADDR: keypad[CONF_ADDR], CONF_BUTTONS: [ { + CONF_LED: button[CONF_LED], CONF_NAME: button[CONF_NAME], CONF_NUMBER: button[CONF_NUMBER], CONF_RELEASE_DELAY: button[CONF_RELEASE_DELAY], diff --git a/homeassistant/components/homeworks/const.py b/homeassistant/components/homeworks/const.py index a4645f1d357..8baf1b6299d 100644 --- a/homeassistant/components/homeworks/const.py +++ b/homeassistant/components/homeworks/const.py @@ -10,6 +10,7 @@ CONF_CONTROLLER_ID = "controller_id" CONF_DIMMERS = "dimmers" CONF_INDEX = "index" CONF_KEYPADS = "keypads" +CONF_LED = "led" CONF_NUMBER = "number" CONF_RATE = "rate" CONF_RELEASE_DELAY = "release_delay" diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index 3f1b6d3cc23..03c09e12888 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -60,10 +60,12 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "number": "Number", + "led": "LED", "release_delay": "Release delay" }, "data_description": { "number": "Button number in the range 1 to 24", + "led": "Enable if the button has a scene select indicator", "release_delay": "Time between press and release, set to 0 to only press" }, "title": "[%key:component::homeworks::options::step::init::menu_options::add_keypad%]" @@ -92,9 +94,11 @@ }, "edit_button": { "data": { + "led": "[%key:component::homeworks::options::step::add_button::data::led%]", "release_delay": "[%key:component::homeworks::options::step::add_button::data::release_delay%]" }, "data_description": { + "led": "[%key:component::homeworks::options::step::add_button::data_description::led%]", "release_delay": "[%key:component::homeworks::options::step::add_button::data_description::release_delay%]" }, "title": "[%key:component::homeworks::options::step::edit_keypad::menu_options::select_edit_button%]" diff --git a/tests/components/homeworks/conftest.py b/tests/components/homeworks/conftest.py index d1366f89641..b446ce03c5e 100644 --- a/tests/components/homeworks/conftest.py +++ b/tests/components/homeworks/conftest.py @@ -11,6 +11,7 @@ from homeassistant.components.homeworks.const import ( CONF_CONTROLLER_ID, CONF_DIMMERS, CONF_KEYPADS, + CONF_LED, CONF_NUMBER, CONF_RATE, CONF_RELEASE_DELAY, @@ -47,16 +48,19 @@ def mock_config_entry() -> MockConfigEntry: { CONF_NAME: "Morning", CONF_NUMBER: 1, + CONF_LED: True, CONF_RELEASE_DELAY: None, }, { CONF_NAME: "Relax", CONF_NUMBER: 2, + CONF_LED: True, CONF_RELEASE_DELAY: None, }, { CONF_NAME: "Dim up", CONF_NUMBER: 3, + CONF_LED: False, CONF_RELEASE_DELAY: 0.2, }, ], diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py index 00980b2f60c..4bdb5938f1c 100644 --- a/tests/components/homeworks/test_config_flow.py +++ b/tests/components/homeworks/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import ANY, MagicMock import pytest from pytest_unordered import unordered +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.homeworks.const import ( CONF_ADDR, @@ -12,6 +13,7 @@ from homeassistant.components.homeworks.const import ( CONF_DIMMERS, CONF_INDEX, CONF_KEYPADS, + CONF_LED, CONF_NUMBER, CONF_RATE, CONF_RELEASE_DELAY, @@ -163,16 +165,19 @@ async def test_import_flow( { CONF_NAME: "Morning", CONF_NUMBER: 1, + CONF_LED: True, CONF_RELEASE_DELAY: None, }, { CONF_NAME: "Relax", CONF_NUMBER: 2, + CONF_LED: True, CONF_RELEASE_DELAY: None, }, { CONF_NAME: "Dim up", CONF_NUMBER: 3, + CONF_LED: False, CONF_RELEASE_DELAY: 0.2, }, ], @@ -204,12 +209,13 @@ async def test_import_flow( "addr": "[02:08:02:01]", "buttons": [ { + "led": True, "name": "Morning", "number": 1, "release_delay": None, }, - {"name": "Relax", "number": 2, "release_delay": None}, - {"name": "Dim up", "number": 3, "release_delay": 0.2}, + {"led": True, "name": "Relax", "number": 2, "release_delay": None}, + {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, ], "name": "Foyer Keypad", } @@ -295,12 +301,13 @@ async def test_reconfigure_flow( "addr": "[02:08:02:01]", "buttons": [ { + "led": True, "name": "Morning", "number": 1, "release_delay": None, }, - {"name": "Relax", "number": 2, "release_delay": None}, - {"name": "Dim up", "number": 3, "release_delay": 0.2}, + {"led": True, "name": "Relax", "number": 2, "release_delay": None}, + {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, ], "name": "Foyer Keypad", }, @@ -386,12 +393,13 @@ async def test_reconfigure_flow_flow_no_change( "addr": "[02:08:02:01]", "buttons": [ { + "led": True, "name": "Morning", "number": 1, "release_delay": None, }, - {"name": "Relax", "number": 2, "release_delay": None}, - {"name": "Dim up", "number": 3, "release_delay": 0.2}, + {"led": True, "name": "Relax", "number": 2, "release_delay": None}, + {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, ], "name": "Foyer Keypad", } @@ -492,12 +500,13 @@ async def test_options_add_remove_light_flow( "addr": "[02:08:02:01]", "buttons": [ { + "led": True, "name": "Morning", "number": 1, "release_delay": None, }, - {"name": "Relax", "number": 2, "release_delay": None}, - {"name": "Dim up", "number": 3, "release_delay": 0.2}, + {"led": True, "name": "Relax", "number": 2, "release_delay": None}, + {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, ], "name": "Foyer Keypad", } @@ -543,12 +552,13 @@ async def test_options_add_remove_light_flow( "addr": "[02:08:02:01]", "buttons": [ { + "led": True, "name": "Morning", "number": 1, "release_delay": None, }, - {"name": "Relax", "number": 2, "release_delay": None}, - {"name": "Dim up", "number": 3, "release_delay": 0.2}, + {"led": True, "name": "Relax", "number": 2, "release_delay": None}, + {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, ], "name": "Foyer Keypad", } @@ -602,12 +612,13 @@ async def test_options_add_remove_keypad_flow( "addr": "[02:08:02:01]", "buttons": [ { + "led": True, "name": "Morning", "number": 1, "release_delay": None, }, - {"name": "Relax", "number": 2, "release_delay": None}, - {"name": "Dim up", "number": 3, "release_delay": 0.2}, + {"led": True, "name": "Relax", "number": 2, "release_delay": None}, + {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, ], "name": "Foyer Keypad", }, @@ -750,12 +761,13 @@ async def test_options_edit_light_no_lights_flow( "addr": "[02:08:02:01]", "buttons": [ { + "led": True, "name": "Morning", "number": 1, "release_delay": None, }, - {"name": "Relax", "number": 2, "release_delay": None}, - {"name": "Dim up", "number": 3, "release_delay": 0.2}, + {"led": True, "name": "Relax", "number": 2, "release_delay": None}, + {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, ], "name": "Foyer Keypad", } @@ -801,6 +813,7 @@ async def test_options_add_button_flow( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 2 assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 3 result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) @@ -836,6 +849,7 @@ async def test_options_add_button_flow( CONF_NAME: "Dim down", CONF_NUMBER: 4, CONF_RELEASE_DELAY: 0.2, + CONF_LED: True, }, ) await hass.async_block_till_done() @@ -850,13 +864,15 @@ async def test_options_add_button_flow( "addr": "[02:08:02:01]", "buttons": [ { + "led": True, "name": "Morning", "number": 1, "release_delay": None, }, - {"name": "Relax", "number": 2, "release_delay": None}, - {"name": "Dim up", "number": 3, "release_delay": 0.2}, + {"led": True, "name": "Relax", "number": 2, "release_delay": None}, + {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, { + "led": True, "name": "Dim down", "number": 4, "release_delay": 0.2, @@ -871,6 +887,7 @@ async def test_options_add_button_flow( await hass.async_block_till_done() # Check the new entities were added + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 3 assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 4 @@ -881,6 +898,7 @@ async def test_options_add_button_flow_duplicate( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 2 assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 3 result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) @@ -930,6 +948,7 @@ async def test_options_edit_button_flow( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 2 assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 3 result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) @@ -974,6 +993,7 @@ async def test_options_edit_button_flow( result["flow_id"], user_input={ CONF_RELEASE_DELAY: 0, + CONF_LED: False, }, ) await hass.async_block_till_done() @@ -988,12 +1008,13 @@ async def test_options_edit_button_flow( "addr": "[02:08:02:01]", "buttons": [ { + "led": False, "name": "Morning", "number": 1, "release_delay": 0.0, }, - {"name": "Relax", "number": 2, "release_delay": None}, - {"name": "Dim up", "number": 3, "release_delay": 0.2}, + {"led": True, "name": "Relax", "number": 2, "release_delay": None}, + {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, ], "name": "Foyer Keypad", } @@ -1004,6 +1025,7 @@ async def test_options_edit_button_flow( await hass.async_block_till_done() # Check the new entities were added + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 2 assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 3 @@ -1061,8 +1083,8 @@ async def test_options_remove_button_flow( { "addr": "[02:08:02:01]", "buttons": [ - {"name": "Relax", "number": 2, "release_delay": None}, - {"name": "Dim up", "number": 3, "release_delay": 0.2}, + {"led": True, "name": "Relax", "number": 2, "release_delay": None}, + {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, ], "name": "Foyer Keypad", } @@ -1073,4 +1095,5 @@ async def test_options_remove_button_flow( await hass.async_block_till_done() # Check the entities were removed + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2