Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce send_telnet_commands service for denonavr receivers #96457

Closed
wants to merge 2 commits into from

Conversation

ol-iver
Copy link
Contributor

@ol-iver ol-iver commented Jul 12, 2023

Breaking change

Proposed change

This PR adds send_telnet_commands to denonavr receivers which allow sending one or multiple telnet commands to the device and subscribe for their results.
Since there is not explicit start and end for the responses, the service waits for a 5 seconds timeout (DEFAULT_TIMEOUT) or for 0.2 seconds after the last message arrived (whatever happens first).

Along with this new feature denonavr library is updated to 0.11.3 (changelog).

In case you need some telnet commands for testing, check out this doc.

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (thank you!)
  • New feature (which adds functionality to an existing integration)
  • Deprecation (breaking change to happen in the future)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

Checklist

  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist
  • I have followed the perfect PR recommendations
  • The code has been formatted using Black (black --fast homeassistant tests)
  • Tests have been added to verify that the new code works.

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • The manifest file has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.
  • Untested files have been added to .coveragerc.

To help with the load of incoming pull requests:

@home-assistant
Copy link

Hey there @starkillerOG, mind taking a look at this pull request as it has been labeled with an integration (denonavr) you are listed as a code owner for? Thanks!

Code owner commands

Code owners of denonavr can trigger bot actions by commenting:

  • @home-assistant close Closes the pull request.
  • @home-assistant rename Awesome new title Renames the pull request.
  • @home-assistant reopen Reopen the pull request.
  • @home-assistant unassign denonavr Removes the current integration label and assignees on the pull request, add the integration domain after the command.

@@ -505,17 +550,17 @@ async def async_mute_volume(self, mute: bool) -> None:
await self._receiver.async_mute(mute)

@async_log_errors
async def async_get_command(self, command: str, **kwargs):
async def async_get_command(self, command: str, **kwargs) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async def async_get_command(self, command: str, **kwargs) -> str:
async def async_get_command(self, command: str, **kwargs: Any) -> str:

@@ -133,6 +153,31 @@ async def async_setup_entry(
f"async_{SERVICE_UPDATE_AUDYSSEY}",
)

async def async_service_handle(service_call: ServiceCall):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing return type

Copy link
Contributor Author

@ol-iver ol-iver Jul 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was not able to enter an response type here. It appears to me mypy fails converting data types of other annotations into JsonObjectType and JsonValueType correctly.
The line could look like

    async def async_service_handle(service_call: ServiceCall) -> ServiceResponse:

But when I do so and try to commit (I'm using the dev-container) mypy fails

homeassistant/components/denonavr/media_player.py:172: error: Incompatible return value type (got "dict[str, list[dict[str, str]] | None]", expected "JsonObjectType | None")  [return-value]
Found 1 error in 1 file (checked 1 source file)

However, when I build a fake return for line 172 which looks like the "incompatible return type" from the message mypy succeeds.

        return {"asd": [{"asdf": "asd", "xyz": "asd"}], "xyz": None}

Changing the return type of async_send_telnet_commands method just moves the problem up the chain.

Can you help me with that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't get the typing to work without a cast either

diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py
index 7b7eb30aa6..8cf83b4b83 100644
--- a/homeassistant/components/denonavr/media_player.py
+++ b/homeassistant/components/denonavr/media_player.py
@@ -7,7 +7,7 @@ from datetime import timedelta
 from functools import wraps
 import logging
 import time
-from typing import Any, Concatenate, ParamSpec, TypeVar
+from typing import Any, Concatenate, ParamSpec, TypeVar, cast
 
 from denonavr import DenonAVR
 from denonavr.const import (
@@ -39,6 +39,7 @@ from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL
 from homeassistant.core import (
     HomeAssistant,
     ServiceCall,
+    ServiceResponse,
     SupportsResponse,
 )
 from homeassistant.exceptions import HomeAssistantError
@@ -111,7 +112,7 @@ async def async_setup_entry(
     async_add_entities: AddEntitiesCallback,
 ) -> None:
     """Set up the DenonAVR receiver from a config entry."""
-    entities = []
+    entities: list[DenonDevice] = []  # This should be named DenonMediaPlayerEntity
     data = hass.data[DOMAIN][config_entry.entry_id]
     receiver = data[CONF_RECEIVER]
     update_audyssey = config_entry.options.get(
@@ -153,27 +154,30 @@ async def async_setup_entry(
         f"async_{SERVICE_UPDATE_AUDYSSEY}",
     )
 
-    async def async_service_handle(service_call: ServiceCall):
+    async def _async_telnet_commands_service(
+        service_call: ServiceCall,
+    ) -> ServiceResponse:
         """Handle dispatched services."""
         assert platform is not None
-        entities = await platform.async_extract_from_service(service_call)
-
-        response = {}
-
-        for entity in entities:
-            assert isinstance(entity, DenonDevice)
-
-            if service_call.service == SERVICE_TELNET_COMMANDS:
-                response[entity.entity_id] = await entity.async_send_telnet_commands(
-                    service_call.data[ATTR_COMMAND]
+        entities = cast(
+            list[DenonDevice], await platform.async_extract_from_service(service_call)
+        )
+        command_results = await asyncio.gather(
+            entity.async_send_telnet_commands(service_call.data[ATTR_COMMAND])
+            for entity in entities
+        )
+        return cast(
+            ServiceResponse,
+            {
+                entity.entity_id: command_results[idx]
+                for idx, entity in enumerate(entities)
+            },
         )
-
-        return response
 
     hass.services.async_register(
         DOMAIN,
         SERVICE_TELNET_COMMANDS,
-        async_service_handle,
+        _async_telnet_commands_service,
         schema=cv.make_entity_service_schema({vol.Required(ATTR_COMMAND): cv.string}),
         supports_response=SupportsResponse.ONLY,
     )
@@ -587,7 +591,7 @@ class DenonDevice(MediaPlayerEntity):
                     f"Invalid command {command}. Valid commands start with these prefixes: {TELNET_EVENTS_SORTED}"
                 )
 
-        result = []
+        result: list[dict[str, str]] = []
         times = {"last_message": 0.0}
 
         async def async_telnet_callback(zone: str, event: str, parameter: str) -> None:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will give it a try later. Thanks 😄

found = True
break
if not found:
raise HomeAssistantError(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +602 to +616
success = self._receiver.send_telnet_commands(*command_list)
if success:
while True:
if time.monotonic() - times["start"] > DEFAULT_TIMEOUT:
_LOGGER.debug("Telnet command returns because timeout elapsed")
break
if (
times["last_message"]
and time.monotonic() - times["last_message"] > 0.2
):
_LOGGER.debug(
"Telnet command returns because last message timeout elapsed"
)
break
await asyncio.sleep(0.1)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe wrap this in a try and do the unregister in finally in case anything fails it doesn't end up leaking a registration

Comment on lines +605 to +616
if time.monotonic() - times["start"] > DEFAULT_TIMEOUT:
_LOGGER.debug("Telnet command returns because timeout elapsed")
break
if (
times["last_message"]
and time.monotonic() - times["last_message"] > 0.2
):
_LOGGER.debug(
"Telnet command returns because last message timeout elapsed"
)
break
await asyncio.sleep(0.1)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could use an asyncio.Future instead of sleep/poll/loop

@bdraco
Copy link
Member

bdraco commented Jul 13, 2023

I tested general operations with my devices and everything appears to be working as expected.

I did not yet test the new service

@MartinHjelmare
Copy link
Member

I'd like core team input on this before we merge.

@MartinHjelmare MartinHjelmare added the second-opinion-wanted Add this label when a reviewer needs a second opinion from another member. label Jul 13, 2023
@bdraco
Copy link
Member

bdraco commented Jul 13, 2023

Its a debatable if allowing telnet commands violates this rule:
https://developers.home-assistant.io/docs/creating_component_code_review?_highlight=pypi#4-communication-with-devicesservices

We have remotes that allow sending commands like Play, Pause, etc. This seems close to that because the protocol data / API is (mostly) human readable, but its a stretch so it needs some more input to make sure we aren't setting the wrong precedent here.

@MartinHjelmare
Copy link
Member

MartinHjelmare commented Jul 13, 2023

Yeah, there are many questions here.

  1. Should it be a remote entity instead?
  2. We currently don't allow multiple entity service responses in a single service call. What's the plan for that?
  3. The integration registers a platform service for an entity without using our entity platform service helper to workaround the fact we haven't added support for service responses there yet. Should we allow that?

@bdraco
Copy link
Member

bdraco commented Jul 13, 2023

I think it would make sense to do the lib bump in another PR while we figure out how to move forward with the service

@ol-iver
Copy link
Contributor Author

ol-iver commented Jul 13, 2023

I think it would make sense to do the lib bump in another PR while we figure out how to move forward with the service

Sounds good, here is the PR for the version update: #96467

Copy link
Member

@balloob balloob left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have limited it on purpose. Use register_entity_service.

assert isinstance(entity, DenonDevice)

if service_call.service == SERVICE_TELNET_COMMANDS:
response[entity.entity_id] = await entity.async_send_telnet_commands(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not allowed. We explicitly do not allow multiple response values from multiple entities to be combined

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused 😕
I started by using register_entity_service because it is very easy to implement. However, it does not support returning any values, so I switched generic service register function, which seems to be the way to go according to the developer docs.
So, here is my question. How should I implement a service for an entity which returns data?

@home-assistant
Copy link

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

@home-assistant home-assistant bot marked this pull request as draft July 13, 2023 12:30
@Rudd-O
Copy link

Rudd-O commented Aug 3, 2023

Can this perhaps be tweaked so that the service call itself takes a parameter "expects response y/n" so the system does not have to wait unnecessarily for a response? Thanks.

@MartinHjelmare
Copy link
Member

Besides the architecture discussion we're waiting for, I think this PR needs to explain more what commands are meant to be executed with the new service. When looking at the protocol documentation it looks like most commands have a response value that is an acknowledgement of the sent command including parameters. I may be misreading, so please clarify this.

Services returning a response is not meant for arbitrary commands and responses that are already available as other features of an entity. There should be a specific need for the service and service response which is not already available. Eg, for media player entities, I could think that a search function searching for songs of an artist could be appropriate (until we standardize that). A service that sets the volume and responds with the volume level set, is not appropriate, as there's already a specific service for that and the response is just an acknowledgement of the service call success.

We also want services returning a response to be as specific as possible so that the response can be described as specific as possible to make the user experience as good as possible. Different service responses are then better exposed via different services.

@MartinHjelmare MartinHjelmare removed the second-opinion-wanted Add this label when a reviewer needs a second opinion from another member. label Aug 10, 2023
@ol-iver
Copy link
Contributor Author

ol-iver commented Sep 30, 2023

The new service is for people who would like to add custom sensors for properties which do not exist in media player entities (like in this issue #92767).
There is already the get_command service which does not return any data and works for the same commands I enlisted in the PR description. Thus, if it is not possible/not wanted to add services which return any data, we could close this PR.

@Rudd-O
Copy link

Rudd-O commented Nov 1, 2023

I could use this to avoid using node-red to create my subwoofer and dialog level sliders.

@MartinHjelmare
Copy link
Member

The new service is for people who would like to add custom sensors for properties which do not exist in media player entities

Why doesn't the integration create these sensors for these properties?

@ol-iver
Copy link
Contributor Author

ol-iver commented Nov 5, 2023

The new service is for people who would like to add custom sensors for properties which do not exist in media player entities

Why doesn't the integration create these sensors for these properties?

The receiver offers too many properties. Additionally, those properties are varying for different models. Please check the doc I attached to the PR description. It shows commands and parameters of one model.

@MartinHjelmare
Copy link
Member

Entities can be disabled by default if they are considered not useful for the majority of users. Isn't it possible to enumerate existing properties for a device?

@Rudd-O
Copy link

Rudd-O commented Nov 16, 2023

Seconding what MartinHjelmare said. Most entities can be disabled on add, and the user can enable them if they so choose. This is what the NUT integration does, for example.

Copy link

There hasn't been any activity on this pull request recently. This pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want to keep it open. Also, please rebase your PR onto the latest dev branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!

@github-actions github-actions bot added the stale label Jan 15, 2024
@Rudd-O
Copy link

Rudd-O commented Jan 20, 2024

Can this continue being polished?

@github-actions github-actions bot removed the stale label Jan 21, 2024
Copy link

There hasn't been any activity on this pull request recently. This pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want to keep it open. Also, please rebase your PR onto the latest dev branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!

@github-actions github-actions bot added the stale label Mar 21, 2024
@github-actions github-actions bot closed this Mar 28, 2024
@github-actions github-actions bot locked and limited conversation to collaborators Mar 29, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

denonavr Audio-Input-Format
6 participants