Add number platform support to Alexa (#86553)

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>
This commit is contained in:
Jan Bouwhuis 2023-01-25 13:34:53 +01:00 committed by GitHub
parent 23c9580a4a
commit f182e314e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 216 additions and 37 deletions

View file

@ -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."""

View file

@ -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)

View file

@ -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)

View file

@ -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",
)

View file

@ -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="")