mirror of
https://github.com/home-assistant/core.git
synced 2025-04-27 02:37:50 +00:00
Add alexa remote support (#120878)
* Updated the AlexaModeController to support the remote domain. Also added an alexa entitiy adapter for the remote domain. * Fixed copy paste mistake. * Fixed power state for remove domain. * Updated the CapabilityResource to support labels with the corresponding locale. This local is read from the users config. * Add the alexa display category 'REMOTE' and use it for the remote capability. * Revert "Updated the CapabilityResource to support labels with the corresponding locale. This local is read from the users config." This reverts commit fbdf37904a3e613f4d3107a1b176fdfe6c9429bf. * Fix error when the remote does not have an activtiy list. * Add tests for the state report of a remote entity. * Add a test for alexas set mode directive for a remote entitiy. * Add a test for alexas TurnOn and TurnOff directives for a remote entity. * Apply suggestions from code review Fix copy paste mistakes. Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com> * Improve attribute name as suggested. Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com> * Add test case with zero and one activity. * Add a comment why we use the mode controller instead of the input controller. * Add test to check of the discovery returns all required interfaces for a remote entitiy. * Tweak comment * Add line breaks to fix max allowed chars per line. --------- Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
This commit is contained in:
parent
154da1b18b
commit
e44f00cf7c
@ -18,6 +18,7 @@ from homeassistant.components import (
|
|||||||
light,
|
light,
|
||||||
media_player,
|
media_player,
|
||||||
number,
|
number,
|
||||||
|
remote,
|
||||||
timer,
|
timer,
|
||||||
vacuum,
|
vacuum,
|
||||||
valve,
|
valve,
|
||||||
@ -438,6 +439,8 @@ class AlexaPowerController(AlexaCapability):
|
|||||||
is_on = self.entity.state == fan.STATE_ON
|
is_on = self.entity.state == fan.STATE_ON
|
||||||
elif self.entity.domain == humidifier.DOMAIN:
|
elif self.entity.domain == humidifier.DOMAIN:
|
||||||
is_on = self.entity.state == humidifier.STATE_ON
|
is_on = self.entity.state == humidifier.STATE_ON
|
||||||
|
elif self.entity.domain == remote.DOMAIN:
|
||||||
|
is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN)
|
||||||
elif self.entity.domain == vacuum.DOMAIN:
|
elif self.entity.domain == vacuum.DOMAIN:
|
||||||
is_on = self.entity.state == vacuum.STATE_CLEANING
|
is_on = self.entity.state == vacuum.STATE_CLEANING
|
||||||
elif self.entity.domain == timer.DOMAIN:
|
elif self.entity.domain == timer.DOMAIN:
|
||||||
@ -1435,6 +1438,12 @@ class AlexaModeController(AlexaCapability):
|
|||||||
if mode in modes:
|
if mode in modes:
|
||||||
return f"{humidifier.ATTR_MODE}.{mode}"
|
return f"{humidifier.ATTR_MODE}.{mode}"
|
||||||
|
|
||||||
|
# Remote Activity
|
||||||
|
if self.instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}":
|
||||||
|
activity = self.entity.attributes.get(remote.ATTR_CURRENT_ACTIVITY, None)
|
||||||
|
if activity in self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST, []):
|
||||||
|
return f"{remote.ATTR_ACTIVITY}.{activity}"
|
||||||
|
|
||||||
# Water heater operation mode
|
# Water heater operation mode
|
||||||
if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
|
if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
|
||||||
operation_mode = self.entity.attributes.get(
|
operation_mode = self.entity.attributes.get(
|
||||||
@ -1549,6 +1558,24 @@ class AlexaModeController(AlexaCapability):
|
|||||||
)
|
)
|
||||||
return self._resource.serialize_capability_resources()
|
return self._resource.serialize_capability_resources()
|
||||||
|
|
||||||
|
# Remote Resource
|
||||||
|
if self.instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}":
|
||||||
|
# Use the mode controller for a remote because the input controller
|
||||||
|
# only allows a preset of names as an input.
|
||||||
|
self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False)
|
||||||
|
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
|
||||||
|
for activity in activities:
|
||||||
|
self._resource.add_mode(
|
||||||
|
f"{remote.ATTR_ACTIVITY}.{activity}", [activity]
|
||||||
|
)
|
||||||
|
# Remotes with a single activity completely break Alexa discovery, add a
|
||||||
|
# fake activity to the mode controller (see issue #53832).
|
||||||
|
if len(activities) == 1:
|
||||||
|
self._resource.add_mode(
|
||||||
|
f"{remote.ATTR_ACTIVITY}.{PRESET_MODE_NA}", [PRESET_MODE_NA]
|
||||||
|
)
|
||||||
|
return self._resource.serialize_capability_resources()
|
||||||
|
|
||||||
# Cover Position Resources
|
# Cover Position Resources
|
||||||
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
|
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
|
||||||
self._resource = AlexaModeResource(
|
self._resource = AlexaModeResource(
|
||||||
|
@ -88,7 +88,7 @@ API_THERMOSTAT_MODES_CUSTOM = {
|
|||||||
API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"}
|
API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"}
|
||||||
|
|
||||||
# AlexaModeController does not like a single mode for the fan preset or humidifier mode,
|
# AlexaModeController does not like a single mode for the fan preset or humidifier mode,
|
||||||
# we add PRESET_MODE_NA if a fan / humidifier has only one preset_mode
|
# we add PRESET_MODE_NA if a fan / humidifier / remote has only one preset_mode
|
||||||
PRESET_MODE_NA = "-"
|
PRESET_MODE_NA = "-"
|
||||||
|
|
||||||
STORAGE_ACCESS_TOKEN = "access_token"
|
STORAGE_ACCESS_TOKEN = "access_token"
|
||||||
|
@ -27,6 +27,7 @@ from homeassistant.components import (
|
|||||||
lock,
|
lock,
|
||||||
media_player,
|
media_player,
|
||||||
number,
|
number,
|
||||||
|
remote,
|
||||||
scene,
|
scene,
|
||||||
script,
|
script,
|
||||||
sensor,
|
sensor,
|
||||||
@ -196,6 +197,10 @@ class DisplayCategory:
|
|||||||
# Indicates a device that prints.
|
# Indicates a device that prints.
|
||||||
PRINTER = "PRINTER"
|
PRINTER = "PRINTER"
|
||||||
|
|
||||||
|
# Indicates a decive that support stateless events,
|
||||||
|
# such as remote switches and smart buttons.
|
||||||
|
REMOTE = "REMOTE"
|
||||||
|
|
||||||
# Indicates a network router.
|
# Indicates a network router.
|
||||||
ROUTER = "ROUTER"
|
ROUTER = "ROUTER"
|
||||||
|
|
||||||
@ -645,6 +650,24 @@ class FanCapabilities(AlexaEntity):
|
|||||||
yield Alexa(self.entity)
|
yield Alexa(self.entity)
|
||||||
|
|
||||||
|
|
||||||
|
@ENTITY_ADAPTERS.register(remote.DOMAIN)
|
||||||
|
class RemoteCapabilities(AlexaEntity):
|
||||||
|
"""Class to represent Remote capabilities."""
|
||||||
|
|
||||||
|
def default_display_categories(self) -> list[str]:
|
||||||
|
"""Return the display categories for this entity."""
|
||||||
|
return [DisplayCategory.REMOTE]
|
||||||
|
|
||||||
|
def interfaces(self) -> Generator[AlexaCapability]:
|
||||||
|
"""Yield the supported interfaces."""
|
||||||
|
yield AlexaPowerController(self.entity)
|
||||||
|
yield AlexaModeController(
|
||||||
|
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
|
||||||
|
)
|
||||||
|
yield AlexaEndpointHealth(self.hass, self.entity)
|
||||||
|
yield Alexa(self.entity)
|
||||||
|
|
||||||
|
|
||||||
@ENTITY_ADAPTERS.register(humidifier.DOMAIN)
|
@ENTITY_ADAPTERS.register(humidifier.DOMAIN)
|
||||||
class HumidifierCapabilities(AlexaEntity):
|
class HumidifierCapabilities(AlexaEntity):
|
||||||
"""Class to represent Humidifier capabilities."""
|
"""Class to represent Humidifier capabilities."""
|
||||||
|
@ -21,6 +21,7 @@ from homeassistant.components import (
|
|||||||
light,
|
light,
|
||||||
media_player,
|
media_player,
|
||||||
number,
|
number,
|
||||||
|
remote,
|
||||||
timer,
|
timer,
|
||||||
vacuum,
|
vacuum,
|
||||||
valve,
|
valve,
|
||||||
@ -185,6 +186,8 @@ async def async_api_turn_on(
|
|||||||
service = fan.SERVICE_TURN_ON
|
service = fan.SERVICE_TURN_ON
|
||||||
elif domain == humidifier.DOMAIN:
|
elif domain == humidifier.DOMAIN:
|
||||||
service = humidifier.SERVICE_TURN_ON
|
service = humidifier.SERVICE_TURN_ON
|
||||||
|
elif domain == remote.DOMAIN:
|
||||||
|
service = remote.SERVICE_TURN_ON
|
||||||
elif domain == vacuum.DOMAIN:
|
elif domain == vacuum.DOMAIN:
|
||||||
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||||
if (
|
if (
|
||||||
@ -234,6 +237,8 @@ async def async_api_turn_off(
|
|||||||
service = climate.SERVICE_TURN_OFF
|
service = climate.SERVICE_TURN_OFF
|
||||||
elif domain == fan.DOMAIN:
|
elif domain == fan.DOMAIN:
|
||||||
service = fan.SERVICE_TURN_OFF
|
service = fan.SERVICE_TURN_OFF
|
||||||
|
elif domain == remote.DOMAIN:
|
||||||
|
service = remote.SERVICE_TURN_OFF
|
||||||
elif domain == humidifier.DOMAIN:
|
elif domain == humidifier.DOMAIN:
|
||||||
service = humidifier.SERVICE_TURN_OFF
|
service = humidifier.SERVICE_TURN_OFF
|
||||||
elif domain == vacuum.DOMAIN:
|
elif domain == vacuum.DOMAIN:
|
||||||
@ -1200,6 +1205,17 @@ async def async_api_set_mode(
|
|||||||
msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'"
|
msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'"
|
||||||
raise AlexaInvalidValueError(msg)
|
raise AlexaInvalidValueError(msg)
|
||||||
|
|
||||||
|
# Remote Activity
|
||||||
|
if instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}":
|
||||||
|
activity = mode.split(".")[1]
|
||||||
|
activities: list[str] | None = entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
|
||||||
|
if activity != PRESET_MODE_NA and activities and activity in activities:
|
||||||
|
service = remote.SERVICE_TURN_ON
|
||||||
|
data[remote.ATTR_ACTIVITY] = activity
|
||||||
|
else:
|
||||||
|
msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'"
|
||||||
|
raise AlexaInvalidValueError(msg)
|
||||||
|
|
||||||
# Water heater operation mode
|
# Water heater operation mode
|
||||||
elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
|
elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
|
||||||
operation_mode = mode.split(".")[1]
|
operation_mode = mode.split(".")[1]
|
||||||
|
@ -48,6 +48,41 @@ from .test_common import (
|
|||||||
from tests.common import async_mock_service
|
from tests.common import async_mock_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"curr_activity",
|
||||||
|
"activity_list",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
("TV", ["TV", "MUSIC", "DVD"]),
|
||||||
|
("TV", ["TV"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_discovery_remote(
|
||||||
|
hass: HomeAssistant, curr_activity: str, activity_list: list[str]
|
||||||
|
) -> None:
|
||||||
|
"""Test discory for a remote entity."""
|
||||||
|
request = get_new_request("Alexa.Discovery", "Discover")
|
||||||
|
# setup test device
|
||||||
|
hass.states.async_set(
|
||||||
|
"remote.test",
|
||||||
|
"off",
|
||||||
|
{
|
||||||
|
"current_activity": curr_activity,
|
||||||
|
"activity_list": activity_list,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
msg = await smart_home.async_handle_message(hass, get_default_config(hass), request)
|
||||||
|
assert "event" in msg
|
||||||
|
msg = msg["event"]
|
||||||
|
assert len(msg["payload"]["endpoints"]) == 1
|
||||||
|
endpoint = msg["payload"]["endpoints"][0]
|
||||||
|
assert endpoint["endpointId"] == "remote#test"
|
||||||
|
interfaces = {capability["interface"] for capability in endpoint["capabilities"]}
|
||||||
|
assert "Alexa.PowerController" in interfaces
|
||||||
|
assert "Alexa.ModeController" in interfaces
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("adjust", ["-5", "5", "-80"])
|
@pytest.mark.parametrize("adjust", ["-5", "5", "-80"])
|
||||||
async def test_api_adjust_brightness(hass: HomeAssistant, adjust: str) -> None:
|
async def test_api_adjust_brightness(hass: HomeAssistant, adjust: str) -> None:
|
||||||
"""Test api adjust brightness process."""
|
"""Test api adjust brightness process."""
|
||||||
@ -243,6 +278,102 @@ async def test_api_select_input(
|
|||||||
assert call.data["source"] == source_list[idx]
|
assert call.data["source"] == source_list[idx]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"target_activity",
|
||||||
|
"activity_list",
|
||||||
|
"current_activity_index",
|
||||||
|
"target_activity_index",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
("TV", ["TV", "MUSIC", "DVD"], 1, 0),
|
||||||
|
("MUSIC", ["TV", "MUSIC", "DVD", 1000], 0, 1),
|
||||||
|
("DVD", ["TV", "MUSIC", "DVD", None], 0, 2),
|
||||||
|
("BAD DEVICE", ["TV", "MUSIC", "DVD"], 0, None),
|
||||||
|
("TV", ["TV"], 0, 0),
|
||||||
|
("BAD DEVICE", [], None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_api_select_activity(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
target_activity: str,
|
||||||
|
activity_list: list[str],
|
||||||
|
current_activity_index: int | None,
|
||||||
|
target_activity_index: int | None,
|
||||||
|
) -> None:
|
||||||
|
"""Test api set activity process."""
|
||||||
|
curr_activty = (
|
||||||
|
activity_list[current_activity_index] if current_activity_index else "None"
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"remote.test",
|
||||||
|
"off",
|
||||||
|
{
|
||||||
|
"current_activity": curr_activty,
|
||||||
|
"activity_list": activity_list,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# test where no source matches
|
||||||
|
if target_activity_index is None:
|
||||||
|
await assert_request_fails(
|
||||||
|
"Alexa.ModeController",
|
||||||
|
"SetMode",
|
||||||
|
"remote#test",
|
||||||
|
"remote.turn_on",
|
||||||
|
hass,
|
||||||
|
payload={"mode": f"activity.{target_activity}"},
|
||||||
|
instance="remote.activity",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
call, _ = await assert_request_calls_service(
|
||||||
|
"Alexa.ModeController",
|
||||||
|
"SetMode",
|
||||||
|
"remote#test",
|
||||||
|
"remote.turn_on",
|
||||||
|
hass,
|
||||||
|
payload={"mode": f"activity.{target_activity}"},
|
||||||
|
instance="remote.activity",
|
||||||
|
)
|
||||||
|
assert call.data["activity"] == activity_list[target_activity_index]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"curr_state",
|
||||||
|
"target_name",
|
||||||
|
"target_service",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
("on", "TurnOff", "turn_off"),
|
||||||
|
("off", "TurnOn", "turn_on"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_api_remote_set_power_state(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
curr_state: str,
|
||||||
|
target_name: str,
|
||||||
|
target_service: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test api remote set power state process."""
|
||||||
|
hass.states.async_set(
|
||||||
|
"remote.test",
|
||||||
|
curr_state,
|
||||||
|
{
|
||||||
|
"current_activity": ["TV", "MUSIC", "DVD"],
|
||||||
|
"activity_list": "TV",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, msg = await assert_request_calls_service(
|
||||||
|
"Alexa.PowerController",
|
||||||
|
target_name,
|
||||||
|
"remote#test",
|
||||||
|
f"remote.{target_service}",
|
||||||
|
hass,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_report_lock_state(hass: HomeAssistant) -> None:
|
async def test_report_lock_state(hass: HomeAssistant) -> None:
|
||||||
"""Test LockController implements lockState property."""
|
"""Test LockController implements lockState property."""
|
||||||
hass.states.async_set("lock.locked", STATE_LOCKED, {})
|
hass.states.async_set("lock.locked", STATE_LOCKED, {})
|
||||||
@ -619,6 +750,62 @@ async def test_report_fan_direction(hass: HomeAssistant) -> None:
|
|||||||
properties.assert_equal("Alexa.ModeController", "mode", "direction.forward")
|
properties.assert_equal("Alexa.ModeController", "mode", "direction.forward")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_report_remote_power(hass: HomeAssistant) -> None:
|
||||||
|
"""Test ModeController reports remote power state correctly."""
|
||||||
|
hass.states.async_set(
|
||||||
|
"remote.off",
|
||||||
|
"off",
|
||||||
|
{"current_activity": "TV", "activity_list": ["TV", "MUSIC", "DVD"]},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"remote.on",
|
||||||
|
"on",
|
||||||
|
{"current_activity": "TV", "activity_list": ["TV", "MUSIC", "DVD"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
properties = await reported_properties(hass, "remote#off")
|
||||||
|
properties.assert_equal("Alexa.PowerController", "powerState", "OFF")
|
||||||
|
|
||||||
|
properties = await reported_properties(hass, "remote#on")
|
||||||
|
properties.assert_equal("Alexa.PowerController", "powerState", "ON")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_report_remote_activity(hass: HomeAssistant) -> None:
|
||||||
|
"""Test ModeController reports remote activity correctly."""
|
||||||
|
hass.states.async_set(
|
||||||
|
"remote.unknown",
|
||||||
|
"on",
|
||||||
|
{"current_activity": "UNKNOWN"},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"remote.tv",
|
||||||
|
"on",
|
||||||
|
{"current_activity": "TV", "activity_list": ["TV", "MUSIC", "DVD"]},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"remote.music",
|
||||||
|
"on",
|
||||||
|
{"current_activity": "MUSIC", "activity_list": ["TV", "MUSIC", "DVD"]},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
"remote.dvd",
|
||||||
|
"on",
|
||||||
|
{"current_activity": "DVD", "activity_list": ["TV", "MUSIC", "DVD"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
properties = await reported_properties(hass, "remote#unknown")
|
||||||
|
properties.assert_not_has_property("Alexa.ModeController", "mode")
|
||||||
|
|
||||||
|
properties = await reported_properties(hass, "remote#tv")
|
||||||
|
properties.assert_equal("Alexa.ModeController", "mode", "activity.TV")
|
||||||
|
|
||||||
|
properties = await reported_properties(hass, "remote#music")
|
||||||
|
properties.assert_equal("Alexa.ModeController", "mode", "activity.MUSIC")
|
||||||
|
|
||||||
|
properties = await reported_properties(hass, "remote#dvd")
|
||||||
|
properties.assert_equal("Alexa.ModeController", "mode", "activity.DVD")
|
||||||
|
|
||||||
|
|
||||||
async def test_report_cover_range_value(hass: HomeAssistant) -> None:
|
async def test_report_cover_range_value(hass: HomeAssistant) -> None:
|
||||||
"""Test RangeController reports cover position correctly."""
|
"""Test RangeController reports cover position correctly."""
|
||||||
hass.states.async_set(
|
hass.states.async_set(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user