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:
CrazyMan2000 2024-07-09 18:03:47 +02:00 committed by GitHub
parent 154da1b18b
commit e44f00cf7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 254 additions and 1 deletions

View File

@ -18,6 +18,7 @@ from homeassistant.components import (
light,
media_player,
number,
remote,
timer,
vacuum,
valve,
@ -438,6 +439,8 @@ class AlexaPowerController(AlexaCapability):
is_on = self.entity.state == fan.STATE_ON
elif self.entity.domain == humidifier.DOMAIN:
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:
is_on = self.entity.state == vacuum.STATE_CLEANING
elif self.entity.domain == timer.DOMAIN:
@ -1435,6 +1438,12 @@ class AlexaModeController(AlexaCapability):
if mode in modes:
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
if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
operation_mode = self.entity.attributes.get(
@ -1549,6 +1558,24 @@ class AlexaModeController(AlexaCapability):
)
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
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
self._resource = AlexaModeResource(

View File

@ -88,7 +88,7 @@ API_THERMOSTAT_MODES_CUSTOM = {
API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"}
# 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 = "-"
STORAGE_ACCESS_TOKEN = "access_token"

View File

@ -27,6 +27,7 @@ from homeassistant.components import (
lock,
media_player,
number,
remote,
scene,
script,
sensor,
@ -196,6 +197,10 @@ class DisplayCategory:
# Indicates a device that prints.
PRINTER = "PRINTER"
# Indicates a decive that support stateless events,
# such as remote switches and smart buttons.
REMOTE = "REMOTE"
# Indicates a network router.
ROUTER = "ROUTER"
@ -645,6 +650,24 @@ class FanCapabilities(AlexaEntity):
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)
class HumidifierCapabilities(AlexaEntity):
"""Class to represent Humidifier capabilities."""

View File

@ -21,6 +21,7 @@ from homeassistant.components import (
light,
media_player,
number,
remote,
timer,
vacuum,
valve,
@ -185,6 +186,8 @@ async def async_api_turn_on(
service = fan.SERVICE_TURN_ON
elif domain == humidifier.DOMAIN:
service = humidifier.SERVICE_TURN_ON
elif domain == remote.DOMAIN:
service = remote.SERVICE_TURN_ON
elif domain == vacuum.DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (
@ -234,6 +237,8 @@ async def async_api_turn_off(
service = climate.SERVICE_TURN_OFF
elif domain == fan.DOMAIN:
service = fan.SERVICE_TURN_OFF
elif domain == remote.DOMAIN:
service = remote.SERVICE_TURN_OFF
elif domain == humidifier.DOMAIN:
service = humidifier.SERVICE_TURN_OFF
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}'"
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
elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
operation_mode = mode.split(".")[1]

View File

@ -48,6 +48,41 @@ from .test_common import (
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"])
async def test_api_adjust_brightness(hass: HomeAssistant, adjust: str) -> None:
"""Test api adjust brightness process."""
@ -243,6 +278,102 @@ async def test_api_select_input(
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:
"""Test LockController implements lockState property."""
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")
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:
"""Test RangeController reports cover position correctly."""
hass.states.async_set(