From 3d9d79684df5724a1a7e3b77e647f4d9e646eeba Mon Sep 17 00:00:00 2001 From: dcmeglio <21957250+dcmeglio@users.noreply.github.com> Date: Mon, 13 Feb 2023 19:36:09 -0500 Subject: [PATCH] Add support for telnet connections for Denonavr integration (#85980) Co-authored-by: J. Nick Koston --- homeassistant/components/denonavr/__init__.py | 26 ++++++++++++- .../components/denonavr/config_flow.py | 15 ++++++- .../components/denonavr/manifest.json | 4 +- .../components/denonavr/media_player.py | 39 ++++++++++++++++++- homeassistant/components/denonavr/receiver.py | 10 +++++ .../components/denonavr/strings.json | 4 +- homeassistant/generated/integrations.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/denonavr/test_config_flow.py | 13 ++++++- 10 files changed, 105 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index 0f58f5f5218..d9a1300ed0e 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -1,21 +1,26 @@ """The denonavr component.""" import logging +from denonavr import DenonAVR from denonavr.exceptions import AvrNetworkError, AvrTimoutError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.helpers.httpx_client import get_async_client from .config_flow import ( CONF_SHOW_ALL_SOURCES, + CONF_UPDATE_AUDYSSEY, + CONF_USE_TELNET, CONF_ZONE2, CONF_ZONE3, DEFAULT_SHOW_SOURCES, DEFAULT_TIMEOUT, + DEFAULT_UPDATE_AUDYSSEY, + DEFAULT_USE_TELNET, DEFAULT_ZONE2, DEFAULT_ZONE3, DOMAIN, @@ -40,6 +45,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options.get(CONF_SHOW_ALL_SOURCES, DEFAULT_SHOW_SOURCES), entry.options.get(CONF_ZONE2, DEFAULT_ZONE2), entry.options.get(CONF_ZONE3, DEFAULT_ZONE3), + entry.options.get(CONF_USE_TELNET, DEFAULT_USE_TELNET), + entry.options.get(CONF_UPDATE_AUDYSSEY, DEFAULT_UPDATE_AUDYSSEY), lambda: get_async_client(hass), ) try: @@ -56,6 +63,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + use_telnet = entry.options.get(CONF_USE_TELNET, DEFAULT_USE_TELNET) + + async def _async_disconnect(event: Event) -> None: + """Disconnect from Telnet.""" + if use_telnet and receiver is not None: + await receiver.async_telnet_disconnect() + + if use_telnet: + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect) + ) return True @@ -66,6 +84,10 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, PLATFORMS ) + if config_entry.options.get(CONF_USE_TELNET, DEFAULT_USE_TELNET): + receiver: DenonAVR = hass.data[DOMAIN][config_entry.entry_id][CONF_RECEIVER] + await receiver.async_telnet_disconnect() + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() # Remove zone2 and zone3 entities if needed diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 2afe5828530..2a6669f5606 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -31,12 +31,15 @@ CONF_ZONE3 = "zone3" CONF_MANUFACTURER = "manufacturer" CONF_SERIAL_NUMBER = "serial_number" CONF_UPDATE_AUDYSSEY = "update_audyssey" +CONF_USE_TELNET = "use_telnet" DEFAULT_SHOW_SOURCES = False DEFAULT_TIMEOUT = 5 DEFAULT_ZONE2 = False DEFAULT_ZONE3 = False DEFAULT_UPDATE_AUDYSSEY = False +DEFAULT_USE_TELNET = False +DEFAULT_USE_TELNET_NEW_INSTALL = True CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_HOST): str}) @@ -77,6 +80,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_UPDATE_AUDYSSEY, DEFAULT_UPDATE_AUDYSSEY ), ): bool, + vol.Optional( + CONF_USE_TELNET, + default=self.config_entry.options.get( + CONF_USE_TELNET, DEFAULT_USE_TELNET + ), + ): bool, } ) @@ -97,6 +106,7 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.show_all_sources = DEFAULT_SHOW_SOURCES self.zone2 = DEFAULT_ZONE2 self.zone3 = DEFAULT_ZONE3 + self.use_telnet = DEFAULT_USE_TELNET_NEW_INSTALL self.d_receivers: list[dict[str, Any]] = [] @staticmethod @@ -176,7 +186,9 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.show_all_sources, self.zone2, self.zone3, - lambda: get_async_client(self.hass), + use_telnet=False, + update_audyssey=False, + async_client_getter=lambda: get_async_client(self.hass), ) try: @@ -216,6 +228,7 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_MANUFACTURER: receiver.manufacturer, CONF_SERIAL_NUMBER: self.serial_number, }, + options={CONF_USE_TELNET: DEFAULT_USE_TELNET_NEW_INSTALL}, ) async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index b760610d05c..2d6a127ff37 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@ol-iver", "@starkillerOG"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/denonavr", - "iot_class": "local_polling", + "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.10.12"], + "requirements": ["denonavr==0.11.1"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index b5d831ad9cc..1d153077cdf 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -247,12 +247,47 @@ class DenonDevice(MediaPlayerEntity): and MediaPlayerEntityFeature.SELECT_SOUND_MODE ) + self._receiver.register_callback("ALL", self._telnet_callback) + + self._telnet_was_healthy: bool | None = None + + async def _telnet_callback(self, zone, event, parameter): + """Process a telnet command callback.""" + if zone != self._receiver.zone: + return + + self.async_write_ha_state() + + async def async_will_remove_from_hass(self) -> None: + """Clean up the entity.""" + self._receiver.unregister_callback("ALL", self._telnet_callback) + @async_log_errors async def async_update(self) -> None: """Get the latest status information from device.""" - await self._receiver.async_update() + receiver = self._receiver + + # We can only skip the update if telnet was healthy after + # the last update and is still healthy now to ensure that + # we don't miss any state changes while telnet is down + # or reconnecting. + if ( + telnet_is_healthy := receiver.telnet_connected and receiver.telnet_healthy + ) and self._telnet_was_healthy: + await receiver.input.async_update_media_state() + return + + # if async_update raises an exception, we don't want to skip the next update + # so we set _telnet_was_healthy to None here and only set it to the value + # before the update if the update was successful + self._telnet_was_healthy = None + + await receiver.async_update() + + self._telnet_was_healthy = telnet_is_healthy + if self._update_audyssey: - await self._receiver.async_update_audyssey() + await receiver.async_update_audyssey() @property def state(self) -> MediaPlayerState | None: diff --git a/homeassistant/components/denonavr/receiver.py b/homeassistant/components/denonavr/receiver.py index 7ea461f4e6f..71fa77718e6 100644 --- a/homeassistant/components/denonavr/receiver.py +++ b/homeassistant/components/denonavr/receiver.py @@ -19,6 +19,8 @@ class ConnectDenonAVR: show_all_inputs: bool, zone2: bool, zone3: bool, + use_telnet: bool, + update_audyssey: bool, async_client_getter: Callable, ) -> None: """Initialize the class.""" @@ -27,6 +29,8 @@ class ConnectDenonAVR: self._host = host self._show_all_inputs = show_all_inputs self._timeout = timeout + self._use_telnet = use_telnet + self._update_audyssey = update_audyssey self._zones: dict[str, str | None] = {} if zone2: @@ -85,5 +89,11 @@ class ConnectDenonAVR: # Use httpx.AsyncClient getter provided by Home Assistant receiver.set_async_client_getter(self._async_client_getter) await receiver.async_setup() + # Do an initial update if telnet is used. + if self._use_telnet: + await receiver.async_update() + if self._update_audyssey: + await receiver.async_update_audyssey() + await receiver.async_telnet_connect() self._receiver = receiver diff --git a/homeassistant/components/denonavr/strings.json b/homeassistant/components/denonavr/strings.json index e2a90a5c01b..1c85efc9ff4 100644 --- a/homeassistant/components/denonavr/strings.json +++ b/homeassistant/components/denonavr/strings.json @@ -3,6 +3,7 @@ "flow_title": "{name}", "step": { "user": { + "description": "By default, this integration uses a Telnet connection to your receiver to receive real-time updates. Only one Telnet connection to your receiver can be established at a time. The Telnet connection can be disabled after setting up the integration.", "data": { "host": "[%key:common::config_flow::data::ip%]" }, @@ -40,7 +41,8 @@ "show_all_sources": "Show all sources", "zone2": "Set up Zone 2", "zone3": "Set up Zone 3", - "update_audyssey": "Update Audyssey settings" + "update_audyssey": "Update Audyssey settings", + "use_telnet": "Use Telnet connection" } } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d1e1307d8bc..2e97341a195 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -997,7 +997,7 @@ "denonavr": { "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", + "iot_class": "local_push", "name": "Denon AVR Network Receivers" }, "heos": { diff --git a/requirements_all.txt b/requirements_all.txt index e2513b86dab..5f1637c7a79 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -586,7 +586,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.10.12 +denonavr==0.11.1 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdfe59fcb36..3cf3b1b1b95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -463,7 +463,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.10.12 +denonavr==0.11.1 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py index b0618b9922f..93a6305655b 100644 --- a/tests/components/denonavr/test_config_flow.py +++ b/tests/components/denonavr/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.denonavr.config_flow import ( CONF_SHOW_ALL_SOURCES, CONF_TYPE, CONF_UPDATE_AUDYSSEY, + CONF_USE_TELNET, CONF_ZONE2, CONF_ZONE3, DOMAIN, @@ -96,6 +97,7 @@ async def test_config_flow_manual_host_success(hass: HomeAssistant) -> None: CONF_MANUFACTURER: TEST_MANUFACTURER, CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, } + assert result["options"] == {CONF_USE_TELNET: True} async def test_config_flow_manual_discover_1_success(hass: HomeAssistant) -> None: @@ -129,6 +131,7 @@ async def test_config_flow_manual_discover_1_success(hass: HomeAssistant) -> Non CONF_MANUFACTURER: TEST_MANUFACTURER, CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, } + assert result["options"] == {CONF_USE_TELNET: True} async def test_config_flow_manual_discover_2_success(hass: HomeAssistant) -> None: @@ -171,6 +174,7 @@ async def test_config_flow_manual_discover_2_success(hass: HomeAssistant) -> Non CONF_MANUFACTURER: TEST_MANUFACTURER, CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, } + assert result["options"] == {CONF_USE_TELNET: True} async def test_config_flow_manual_discover_error(hass: HomeAssistant) -> None: @@ -322,6 +326,7 @@ async def test_config_flow_ssdp(hass: HomeAssistant) -> None: CONF_MANUFACTURER: TEST_MANUFACTURER, CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, } + assert result["options"] == {CONF_USE_TELNET: True} async def test_config_flow_ssdp_not_denon(hass: HomeAssistant) -> None: @@ -421,7 +426,12 @@ async def test_options_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_SHOW_ALL_SOURCES: True, CONF_ZONE2: True, CONF_ZONE3: True}, + user_input={ + CONF_SHOW_ALL_SOURCES: True, + CONF_ZONE2: True, + CONF_ZONE3: True, + CONF_USE_TELNET: False, + }, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY @@ -430,6 +440,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_ZONE2: True, CONF_ZONE3: True, CONF_UPDATE_AUDYSSEY: False, + CONF_USE_TELNET: False, }