Add number platform support to Alexa (#86553)
Co-authored-by: Mike Degatano <michael.degatano@gmail.com>
This commit is contained in:
parent
23c9580a4a
commit
f182e314e5
5 changed files with 216 additions and 37 deletions
|
@ -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."""
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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="")
|
||||
|
|
Loading…
Add table
Reference in a new issue