From f182e314e508ab4a0d77f7aa0b0b0588a9d7c42c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 25 Jan 2023 13:34:53 +0100 Subject: [PATCH] Add number platform support to Alexa (#86553) Co-authored-by: Mike Degatano --- .../components/alexa/capabilities.py | 85 +++++++++++++++-- homeassistant/components/alexa/entities.py | 10 +- homeassistant/components/alexa/handlers.py | 20 ++++ tests/components/alexa/test_smart_home.py | 46 +++++----- tests/components/alexa/test_state_report.py | 92 +++++++++++++++++++ 5 files changed, 216 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index ca497ade9ad..0feecbd6d24 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -14,6 +14,7 @@ from homeassistant.components import ( input_number, light, media_player, + number, timer, vacuum, ) @@ -26,6 +27,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, @@ -41,6 +43,10 @@ from homeassistant.const import ( STATE_UNKNOWN, STATE_UNLOCKED, STATE_UNLOCKING, + UnitOfLength, + UnitOfMass, + UnitOfTemperature, + UnitOfVolume, ) from homeassistant.core import State import homeassistant.util.color as color_util @@ -65,6 +71,34 @@ from .resources import ( _LOGGER = logging.getLogger(__name__) +UNIT_TO_CATALOG_TAG = { + UnitOfTemperature.CELSIUS: AlexaGlobalCatalog.UNIT_TEMPERATURE_CELSIUS, + UnitOfTemperature.FAHRENHEIT: AlexaGlobalCatalog.UNIT_TEMPERATURE_FAHRENHEIT, + UnitOfTemperature.KELVIN: AlexaGlobalCatalog.UNIT_TEMPERATURE_KELVIN, + UnitOfLength.METERS: AlexaGlobalCatalog.UNIT_DISTANCE_METERS, + UnitOfLength.KILOMETERS: AlexaGlobalCatalog.UNIT_DISTANCE_KILOMETERS, + UnitOfLength.INCHES: AlexaGlobalCatalog.UNIT_DISTANCE_INCHES, + UnitOfLength.FEET: AlexaGlobalCatalog.UNIT_DISTANCE_FEET, + UnitOfLength.YARDS: AlexaGlobalCatalog.UNIT_DISTANCE_YARDS, + UnitOfLength.MILES: AlexaGlobalCatalog.UNIT_DISTANCE_MILES, + UnitOfMass.GRAMS: AlexaGlobalCatalog.UNIT_MASS_GRAMS, + UnitOfMass.KILOGRAMS: AlexaGlobalCatalog.UNIT_MASS_KILOGRAMS, + UnitOfMass.POUNDS: AlexaGlobalCatalog.UNIT_WEIGHT_POUNDS, + UnitOfMass.OUNCES: AlexaGlobalCatalog.UNIT_WEIGHT_OUNCES, + UnitOfVolume.LITERS: AlexaGlobalCatalog.UNIT_VOLUME_LITERS, + UnitOfVolume.CUBIC_FEET: AlexaGlobalCatalog.UNIT_VOLUME_CUBIC_FEET, + UnitOfVolume.CUBIC_METERS: AlexaGlobalCatalog.UNIT_VOLUME_CUBIC_METERS, + UnitOfVolume.GALLONS: AlexaGlobalCatalog.UNIT_VOLUME_GALLONS, + PERCENTAGE: AlexaGlobalCatalog.UNIT_PERCENT, + "preset": AlexaGlobalCatalog.SETTING_PRESET, +} + + +def get_resource_by_unit_of_measurement(entity: State) -> str: + """Translate the unit of measurement to an Alexa Global Catalog keyword.""" + unit: str = entity.attributes.get("unit_of_measurement", "preset") + return UNIT_TO_CATALOG_TAG.get(unit, AlexaGlobalCatalog.SETTING_PRESET) + class AlexaCapability: """Base class for Alexa capability interfaces. @@ -78,10 +112,16 @@ class AlexaCapability: supported_locales = {"en-US"} - def __init__(self, entity: State, instance: str | None = None) -> None: + def __init__( + self, + entity: State, + instance: str | None = None, + non_controllable_properties: bool | None = None, + ) -> None: """Initialize an Alexa capability.""" self.entity = entity self.instance = instance + self._non_controllable_properties = non_controllable_properties def name(self) -> str: """Return the Alexa API name of this interface.""" @@ -101,7 +141,7 @@ class AlexaCapability: def properties_non_controllable(self) -> bool | None: """Return True if non controllable.""" - return None + return self._non_controllable_properties def get_property(self, name): """Read and return a property. @@ -1310,10 +1350,9 @@ class AlexaModeController(AlexaCapability): def __init__(self, entity, instance, non_controllable=False): """Initialize the entity.""" - super().__init__(entity, instance) + AlexaCapability.__init__(self, entity, instance, non_controllable) self._resource = None self._semantics = None - self.properties_non_controllable = lambda: non_controllable def name(self): """Return the Alexa API name of this interface.""" @@ -1520,12 +1559,13 @@ class AlexaRangeController(AlexaCapability): "pt-BR", } - def __init__(self, entity, instance, non_controllable=False): + def __init__( + self, entity: State, instance: str | None, non_controllable: bool = False + ) -> None: """Initialize the entity.""" - super().__init__(entity, instance) + AlexaCapability.__init__(self, entity, instance, non_controllable) self._resource = None self._semantics = None - self.properties_non_controllable = lambda: non_controllable def name(self): """Return the Alexa API name of this interface.""" @@ -1579,6 +1619,10 @@ class AlexaRangeController(AlexaCapability): if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": return float(self.entity.state) + # Number Value + if self.instance == f"{number.DOMAIN}.{number.ATTR_VALUE}": + return float(self.entity.state) + # Vacuum Fan Speed if self.instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": speed_list = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED_LIST) @@ -1656,7 +1700,29 @@ class AlexaRangeController(AlexaCapability): unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) self._resource = AlexaPresetResource( - ["Value", AlexaGlobalCatalog.SETTING_PRESET], + ["Value", get_resource_by_unit_of_measurement(self.entity)], + min_value=min_value, + max_value=max_value, + precision=precision, + unit=unit, + ) + self._resource.add_preset( + value=min_value, labels=[AlexaGlobalCatalog.VALUE_MINIMUM] + ) + self._resource.add_preset( + value=max_value, labels=[AlexaGlobalCatalog.VALUE_MAXIMUM] + ) + return self._resource.serialize_capability_resources() + + # Number Value + if self.instance == f"{number.DOMAIN}.{number.ATTR_VALUE}": + min_value = float(self.entity.attributes[number.ATTR_MIN]) + max_value = float(self.entity.attributes[number.ATTR_MAX]) + precision = float(self.entity.attributes.get(number.ATTR_STEP, 1)) + unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + self._resource = AlexaPresetResource( + ["Value", get_resource_by_unit_of_measurement(self.entity)], min_value=min_value, max_value=max_value, precision=precision, @@ -1807,10 +1873,9 @@ class AlexaToggleController(AlexaCapability): def __init__(self, entity, instance, non_controllable=False): """Initialize the entity.""" - super().__init__(entity, instance) + AlexaCapability.__init__(self, entity, instance, non_controllable) self._resource = None self._semantics = None - self.properties_non_controllable = lambda: non_controllable def name(self): """Return the Alexa API name of this interface.""" diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 40aec230010..ab0cafe1156 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -23,6 +23,7 @@ from homeassistant.components import ( light, lock, media_player, + number, scene, script, sensor, @@ -853,8 +854,9 @@ class ImageProcessingCapabilities(AlexaEntity): @ENTITY_ADAPTERS.register(input_number.DOMAIN) +@ENTITY_ADAPTERS.register(number.DOMAIN) class InputNumberCapabilities(AlexaEntity): - """Class to represent input_number capabilities.""" + """Class to represent number and input_number capabilities.""" def default_display_categories(self): """Return the display categories for this entity.""" @@ -862,10 +864,8 @@ class InputNumberCapabilities(AlexaEntity): def interfaces(self): """Yield the supported interfaces.""" - - yield AlexaRangeController( - self.entity, instance=f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}" - ) + domain = self.entity.domain + yield AlexaRangeController(self.entity, instance=f"{domain}.value") yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.hass) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 97c7f4297ff..eb23b09627e 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -19,6 +19,7 @@ from homeassistant.components import ( input_number, light, media_player, + number, timer, vacuum, ) @@ -1285,6 +1286,14 @@ async def async_api_set_range( max_value = float(entity.attributes[input_number.ATTR_MAX]) data[input_number.ATTR_VALUE] = min(max_value, max(min_value, range_value)) + # Input Number Value + elif instance == f"{number.DOMAIN}.{number.ATTR_VALUE}": + range_value = float(range_value) + service = number.SERVICE_SET_VALUE + min_value = float(entity.attributes[number.ATTR_MIN]) + max_value = float(entity.attributes[number.ATTR_MAX]) + data[number.ATTR_VALUE] = min(max_value, max(min_value, range_value)) + # Vacuum Fan Speed elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": service = vacuum.SERVICE_SET_FAN_SPEED @@ -1416,6 +1425,17 @@ async def async_api_adjust_range( max_value, max(min_value, range_delta + current) ) + # Number Value + elif instance == f"{number.DOMAIN}.{number.ATTR_VALUE}": + range_delta = float(range_delta) + service = number.SERVICE_SET_VALUE + min_value = float(entity.attributes[number.ATTR_MIN]) + max_value = float(entity.attributes[number.ATTR_MAX]) + current = float(entity.state) + data[number.ATTR_VALUE] = response_value = min( + max_value, max(min_value, range_delta + current) + ) + # Vacuum Fan Speed elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": range_delta = int(range_delta) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 54f5ca38f69..a84d7342490 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -3344,10 +3344,11 @@ async def test_cover_semantics_position_and_tilt(hass): } in tilt_state_mappings -async def test_input_number(hass): - """Test input_number discovery.""" +@pytest.mark.parametrize("domain", ["input_number", "number"]) +async def test_input_number(hass, domain: str): + """Test input_number and number discovery.""" device = ( - "input_number.test_slider", + f"{domain}.test_slider", 30, { "initial": 30, @@ -3360,7 +3361,7 @@ async def test_input_number(hass): ) appliance = await discovery_test(device, hass) - assert appliance["endpointId"] == "input_number#test_slider" + assert appliance["endpointId"] == f"{domain}#test_slider" assert appliance["displayCategories"][0] == "OTHER" assert appliance["friendlyName"] == "Test Slider" @@ -3369,7 +3370,7 @@ async def test_input_number(hass): ) range_capability = get_capability( - capabilities, "Alexa.RangeController", "input_number.value" + capabilities, "Alexa.RangeController", f"{domain}.value" ) capability_resources = range_capability["capabilityResources"] @@ -3409,11 +3410,11 @@ async def test_input_number(hass): call, _ = await assert_request_calls_service( "Alexa.RangeController", "SetRangeValue", - "input_number#test_slider", - "input_number.set_value", + f"{domain}#test_slider", + f"{domain}.set_value", hass, payload={"rangeValue": 10}, - instance="input_number.value", + instance=f"{domain}.value", ) assert call.data["value"] == 10 @@ -3422,17 +3423,18 @@ async def test_input_number(hass): [(25, -5, False), (35, 5, False), (-20, -100, False), (35, 100, False)], "Alexa.RangeController", "AdjustRangeValue", - "input_number#test_slider", - "input_number.set_value", + f"{domain}#test_slider", + f"{domain}.set_value", "value", - instance="input_number.value", + instance=f"{domain}.value", ) -async def test_input_number_float(hass): - """Test input_number discovery.""" +@pytest.mark.parametrize("domain", ["input_number", "number"]) +async def test_input_number_float(hass, domain: str): + """Test input_number and number discovery.""" device = ( - "input_number.test_slider_float", + f"{domain}.test_slider_float", 0.5, { "initial": 0.5, @@ -3445,7 +3447,7 @@ async def test_input_number_float(hass): ) appliance = await discovery_test(device, hass) - assert appliance["endpointId"] == "input_number#test_slider_float" + assert appliance["endpointId"] == f"{domain}#test_slider_float" assert appliance["displayCategories"][0] == "OTHER" assert appliance["friendlyName"] == "Test Slider Float" @@ -3454,7 +3456,7 @@ async def test_input_number_float(hass): ) range_capability = get_capability( - capabilities, "Alexa.RangeController", "input_number.value" + capabilities, "Alexa.RangeController", f"{domain}.value" ) capability_resources = range_capability["capabilityResources"] @@ -3494,11 +3496,11 @@ async def test_input_number_float(hass): call, _ = await assert_request_calls_service( "Alexa.RangeController", "SetRangeValue", - "input_number#test_slider_float", - "input_number.set_value", + f"{domain}#test_slider_float", + f"{domain}.set_value", hass, payload={"rangeValue": 0.333}, - instance="input_number.value", + instance=f"{domain}.value", ) assert call.data["value"] == 0.333 @@ -3513,10 +3515,10 @@ async def test_input_number_float(hass): ], "Alexa.RangeController", "AdjustRangeValue", - "input_number#test_slider_float", - "input_number.set_value", + f"{domain}#test_slider_float", + f"{domain}.set_value", "value", - instance="input_number.value", + instance=f"{domain}.value", ) diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index cd8e389d172..4cb1e073d5a 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -7,6 +7,8 @@ import pytest from homeassistant import core from homeassistant.components.alexa import errors, state_report +from homeassistant.components.alexa.resources import AlexaGlobalCatalog +from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfTemperature from .test_common import TEST_URL, get_default_config @@ -333,6 +335,96 @@ async def test_report_state_humidifier(hass, aioclient_mock): assert call_json["event"]["endpoint"]["endpointId"] == "humidifier#test_humidifier" +@pytest.mark.parametrize( + "domain,value,unit,label", + [ + ( + "number", + 50, + None, + AlexaGlobalCatalog.SETTING_PRESET, + ), + ( + "input_number", + 40, + UnitOfLength.METERS, + AlexaGlobalCatalog.UNIT_DISTANCE_METERS, + ), + ( + "number", + 20.5, + UnitOfTemperature.CELSIUS, + AlexaGlobalCatalog.UNIT_TEMPERATURE_CELSIUS, + ), + ( + "input_number", + 40.5, + UnitOfLength.MILLIMETERS, + AlexaGlobalCatalog.SETTING_PRESET, + ), + ( + "number", + 20.5, + PERCENTAGE, + AlexaGlobalCatalog.UNIT_PERCENT, + ), + ], +) +async def test_report_state_number(hass, aioclient_mock, domain, value, unit, label): + """Test proactive state reports with number or input_number instance.""" + aioclient_mock.post(TEST_URL, text="", status=202) + state = { + "friendly_name": f"Test {domain}", + "min": 10, + "max": 100, + "step": 0.1, + } + + if unit: + state["unit_of_measurement"]: unit + + hass.states.async_set( + f"{domain}.test_{domain}", + None, + state, + ) + + await state_report.async_enable_proactive_mode(hass, get_default_config(hass)) + + hass.states.async_set( + f"{domain}.test_{domain}", + value, + state, + ) + + # To trigger event listener + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa" + assert call_json["event"]["header"]["name"] == "ChangeReport" + + change_reports = call_json["event"]["payload"]["change"]["properties"] + + checks = 0 + for report in change_reports: + if report["name"] == "connectivity": + assert report["value"] == {"value": "OK"} + assert report["namespace"] == "Alexa.EndpointHealth" + checks += 1 + if report["name"] == "rangeValue": + assert report["value"] == value + assert report["instance"] == f"{domain}.value" + assert report["namespace"] == "Alexa.RangeController" + checks += 1 + assert checks == 2 + + assert call_json["event"]["endpoint"]["endpointId"] == f"{domain}#test_{domain}" + + async def test_send_add_or_update_message(hass, aioclient_mock): """Test sending an AddOrUpdateReport message.""" aioclient_mock.post(TEST_URL, text="")