From 00f3d96f8247025991d580d4b92bd8289388cc45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Fri, 28 Feb 2025 15:02:53 +0100 Subject: [PATCH] airplay: Add setting for MRP tunnel It is now possible to force or disable set up of MRP over AirPlay tunnel over Airplay (or the default "auto" mode). If set up of remote control fails, that will now render an exception (just like with other protocols on an error) instead of just a log message. Relates to #2629 --- docs/api/pyatv.settings.html | 67 ++++++++++--- pyatv/protocols/airplay/__init__.py | 148 +++++++++++++++------------- pyatv/scripts/atvremote.py | 1 + pyatv/settings.py | 20 ++++ 4 files changed, 154 insertions(+), 82 deletions(-) diff --git a/docs/api/pyatv.settings.html b/docs/api/pyatv.settings.html index 85ed5a641..941800a57 100644 --- a/docs/api/pyatv.settings.html +++ b/docs/api/pyatv.settings.html @@ -24,6 +24,7 @@

  • credentials
  • identifier
  • +
  • mrp_tunnel
  • password
  • @@ -70,6 +71,14 @@

    MrpTunnel

    + + +
  • ProtocolSettings

    • airplay
    • @@ -107,7 +116,7 @@

      Module pyatv.settings

      Settings for configuring pyatv.

      - +
      @@ -126,7 +135,7 @@

      Classes

      Settings related to AirPlay.

      Create a new model by parsing and validating input data from keyword arguments.

      Raises ValidationError if the input data cannot be parsed to form a valid model.

      - +

      Ancestors

      • pydantic.v1.main.BaseModel
      • @@ -142,6 +151,10 @@

        Class variables

        The type of the None singleton.

        +
        var mrp_tunnel -> MrpTunnel
        +
        +

        The type of the None singleton.

        +
        var password -> str | None

        The type of the None singleton.

        @@ -154,7 +167,7 @@

        Class variables

        AirPlay version to use.

        - +

        Ancestors

        • builtins.str
        • @@ -164,15 +177,15 @@

          Class variables

          var Auto = auto
          -

          The type of the None singleton.

          +

          Automatically determine what version to use.

          var V1 = 1
          -

          The type of the None singleton.

          +

          Use version 1 of AirPlay.

          var V2 = 2
          -

          The type of the None singleton.

          +

          Use version 2 of AirPlay.

        @@ -184,7 +197,7 @@

        Class variables

        Settings related to Companion.

        Create a new model by parsing and validating input data from keyword arguments.

        Raises ValidationError if the input data cannot be parsed to form a valid model.

        - +

        Ancestors

        • pydantic.v1.main.BaseModel
        • @@ -210,7 +223,7 @@

          Class variables

          Settings related to DMAP.

          Create a new model by parsing and validating input data from keyword arguments.

          Raises ValidationError if the input data cannot be parsed to form a valid model.

          - +

          Ancestors

          • pydantic.v1.main.BaseModel
          • @@ -236,7 +249,7 @@

            Class variables

            Information related settings.

            Create a new model by parsing and validating input data from keyword arguments.

            Raises ValidationError if the input data cannot be parsed to form a valid model.

            - +

            Ancestors

            • pydantic.v1.main.BaseModel
            • @@ -293,7 +306,7 @@

              Static methods

              Settings related to MRP.

              Create a new model by parsing and validating input data from keyword arguments.

              Raises ValidationError if the input data cannot be parsed to form a valid model.

              - +

              Ancestors

              • pydantic.v1.main.BaseModel
              • @@ -311,6 +324,34 @@

                Class variables

                +
                +class MrpTunnel +(*args, **kwds) +
                +
                +

                How MRP tunneling over AirPlay is handled.

                + +

                Ancestors

                +
                  +
                • builtins.str
                • +
                • enum.Enum
                • +
                +

                Class variables

                +
                +
                var Auto = auto
                +
                +

                Automatically set up MRP tunnel if supported by remote device.

                +
                +
                var Disable = disable
                +
                +

                Fully disable set up of MRP tunnel.

                +
                +
                var Force = force
                +
                +

                Force set up of MRP tunnel even if remote device does not supports it.

                +
                +
                +
                class ProtocolSettings (**data: Any) @@ -319,7 +360,7 @@

                Class variables

                Container for protocol specific settings.

                Create a new model by parsing and validating input data from keyword arguments.

                Raises ValidationError if the input data cannot be parsed to form a valid model.

                - +

                Ancestors

                • pydantic.v1.main.BaseModel
                • @@ -357,7 +398,7 @@

                  Class variables

                  Settings related to RAOP.

                  Create a new model by parsing and validating input data from keyword arguments.

                  Raises ValidationError if the input data cannot be parsed to form a valid model.

                  - +

                  Ancestors

                  • pydantic.v1.main.BaseModel
                  • @@ -403,7 +444,7 @@

                    Class variables

                    Settings container class.

                    Create a new model by parsing and validating input data from keyword arguments.

                    Raises ValidationError if the input data cannot be parsed to form a valid model.

                    - +

                    Ancestors

                    • pydantic.v1.main.BaseModel
                    • diff --git a/pyatv/protocols/airplay/__init__.py b/pyatv/protocols/airplay/__init__.py index 52477a761..0bff2ea03 100644 --- a/pyatv/protocols/airplay/__init__.py +++ b/pyatv/protocols/airplay/__init__.py @@ -46,6 +46,7 @@ airplayv1, airplayv2, ) +from pyatv.settings import MrpTunnel from pyatv.support import net from pyatv.support.device_info import lookup_model, lookup_os from pyatv.support.http import HttpConnection, StaticFileWebServer, http_connect @@ -230,6 +231,75 @@ async def service_info( update_service_details(service) +def _create_mrp_tunnel_data(core: Core, credentials: HapCredentials): + session = AP2Session( + str(core.config.address), core.service.port, credentials, core.settings.info + ) + + # A protocol requires its corresponding service to function, so add a + # dummy one if we don't have one yet + mrp_service = core.config.get_service(Protocol.MRP) + if mrp_service is None: + mrp_service = MutableService(None, Protocol.MRP, core.service.port, {}) + core.config.add_service(mrp_service) + + ( + _, + mrp_connect, + mrp_close, + mrp_device_info, + mrp_interfaces, + mrp_features, + ) = mrp.create_with_connection( + Core( + core.loop, + core.config, + mrp_service, + core.settings, + core.device_listener, + core.session_manager, + core.takeover, + core.state_dispatcher.create_copy(Protocol.MRP), + ), + AirPlayMrpConnection(session, core.device_listener), + requires_heatbeat=False, # Already have heartbeat on control channel + ) + + async def _connect_rc() -> bool: + try: + await session.connect() + await session.setup_remote_control() + session.start_keep_alive(core.device_listener) + except exceptions.HttpError as ex: + if ex.status_code == 470: + raise exceptions.InvalidCredentialsError( + "invalid or missing credentials" + ) from ex + raise + except Exception as ex: + raise exceptions.ProtocolError( + "Failed to set up remote control channel" + ) from ex + + await mrp_connect() + return True + + def _close_rc() -> Set[asyncio.Task]: + tasks = set() + tasks.update(mrp_close()) + tasks.update(session.stop()) + return tasks + + return SetupData( + Protocol.MRP, + _connect_rc, + _close_rc, + mrp_device_info, + mrp_interfaces, + mrp_features, + ) + + def setup( # pylint: disable=too-many-locals core: Core, ) -> Generator[SetupData, None, None]: @@ -301,80 +371,20 @@ def _device_info() -> Dict[str, Any]: yield from raop_setup(raop_core) - # Set up remote control channel if it is supported - if not is_remote_control_supported(core.service, credentials): + mrp_tunnel = core.settings.protocols.airplay.mrp_tunnel + + if mrp_tunnel == MrpTunnel.Disable: + _LOGGER.debug("Remote control tunnel disabled by setting") + elif mrp_tunnel == MrpTunnel.Force: + _LOGGER.debug("Remote control channel is supported (forced)") + yield _create_mrp_tunnel_data(core, credentials) + elif not is_remote_control_supported(core.service, credentials): _LOGGER.debug("Remote control not supported by device") elif credentials.type not in [AuthenticationType.HAP, AuthenticationType.Transient]: _LOGGER.debug("%s not supported by remote control channel", credentials.type) else: _LOGGER.debug("Remote control channel is supported") - - session = AP2Session( - str(core.config.address), core.service.port, credentials, core.settings.info - ) - - # A protocol requires its corresponding service to function, so add a - # dummy one if we don't have one yet - mrp_service = core.config.get_service(Protocol.MRP) - if mrp_service is None: - mrp_service = MutableService(None, Protocol.MRP, core.service.port, {}) - core.config.add_service(mrp_service) - - ( - _, - mrp_connect, - mrp_close, - mrp_device_info, - mrp_interfaces, - mrp_features, - ) = mrp.create_with_connection( - Core( - core.loop, - core.config, - mrp_service, - core.settings, - core.device_listener, - core.session_manager, - core.takeover, - core.state_dispatcher.create_copy(Protocol.MRP), - ), - AirPlayMrpConnection(session, core.device_listener), - requires_heatbeat=False, # Already have heartbeat on control channel - ) - - async def _connect_rc() -> bool: - try: - await session.connect() - await session.setup_remote_control() - session.start_keep_alive(core.device_listener) - except exceptions.HttpError as ex: - if ex.status_code == 470: - _LOGGER.debug( - "Remote control authorization failed, missing credentials" - ) - else: - _LOGGER.exception("Failed to set up remote control channel") - except Exception: - _LOGGER.exception("Failed to set up remote control channel") - else: - await mrp_connect() - return True - return False - - def _close_rc() -> Set[asyncio.Task]: - tasks = set() - tasks.update(mrp_close()) - tasks.update(session.stop()) - return tasks - - yield SetupData( - Protocol.MRP, - _connect_rc, - _close_rc, - mrp_device_info, - mrp_interfaces, - mrp_features, - ) + yield _create_mrp_tunnel_data(core, credentials) def pair(core: Core, **kwargs) -> PairingHandler: diff --git a/pyatv/scripts/atvremote.py b/pyatv/scripts/atvremote.py index e6b557699..8fd7ae7a0 100644 --- a/pyatv/scripts/atvremote.py +++ b/pyatv/scripts/atvremote.py @@ -106,6 +106,7 @@ async def commands(self): _print_commands("User Accounts", interface.UserAccounts) _print_commands("Global", self.__class__) _print_commands("Touch", interface.TouchGestures) + _print_commands("Settings", SettingsCommands) return 0 diff --git a/pyatv/settings.py b/pyatv/settings.py index 624104409..999fd689c 100644 --- a/pyatv/settings.py +++ b/pyatv/settings.py @@ -48,8 +48,26 @@ class AirPlayVersion(str, Enum): """AirPlay version to use.""" Auto = "auto" + """Automatically determine what version to use.""" + V1 = "1" + """Use version 1 of AirPlay.""" + V2 = "2" + """Use version 2 of AirPlay.""" + + +class MrpTunnel(str, Enum): + """How MRP tunneling over AirPlay is handled.""" + + Auto = "auto" + """Automatically set up MRP tunnel if supported by remote device.""" + + Force = "force" + """Force set up of MRP tunnel even if remote device does not supports it.""" + + Disable = "disable" + """Fully disable set up of MRP tunnel.""" # pylint: enable=invalid-name @@ -82,6 +100,8 @@ class AirPlaySettings(BaseModel, extra="ignore"): # type: ignore[call-arg] credentials: Optional[str] = None password: Optional[str] = None + mrp_tunnel: MrpTunnel = MrpTunnel.Auto + class CompanionSettings(BaseModel, extra="ignore"): # type: ignore[call-arg] """Settings related to Companion."""