Skip to content

Commit

Permalink
airplay: Add setting for MRP tunnel
Browse files Browse the repository at this point in the history
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
  • Loading branch information
postlund committed Feb 28, 2025
1 parent d711126 commit e27164b
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 82 deletions.
67 changes: 54 additions & 13 deletions docs/api/pyatv.settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ <h4><code><a title="pyatv.settings.AirPlaySettings" href="#pyatv.settings.AirPla
<ul class="">
<li><code><a title="pyatv.settings.AirPlaySettings.credentials" href="#pyatv.settings.AirPlaySettings.credentials">credentials</a></code></li>
<li><code><a title="pyatv.settings.AirPlaySettings.identifier" href="#pyatv.settings.AirPlaySettings.identifier">identifier</a></code></li>
<li><code><a title="pyatv.settings.AirPlaySettings.mrp_tunnel" href="#pyatv.settings.AirPlaySettings.mrp_tunnel">mrp_tunnel</a></code></li>
<li><code><a title="pyatv.settings.AirPlaySettings.password" href="#pyatv.settings.AirPlaySettings.password">password</a></code></li>
</ul>
</li>
Expand Down Expand Up @@ -70,6 +71,14 @@ <h4><code><a title="pyatv.settings.MrpSettings" href="#pyatv.settings.MrpSetting
</ul>
</li>
<li>
<h4><code><a title="pyatv.settings.MrpTunnel" href="#pyatv.settings.MrpTunnel">MrpTunnel</a></code></h4>
<ul class="">
<li><code><a title="pyatv.settings.MrpTunnel.Auto" href="#pyatv.settings.MrpTunnel.Auto">Auto</a></code></li>
<li><code><a title="pyatv.settings.MrpTunnel.Disable" href="#pyatv.settings.MrpTunnel.Disable">Disable</a></code></li>
<li><code><a title="pyatv.settings.MrpTunnel.Force" href="#pyatv.settings.MrpTunnel.Force">Force</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="pyatv.settings.ProtocolSettings" href="#pyatv.settings.ProtocolSettings">ProtocolSettings</a></code></h4>
<ul class="">
<li><code><a title="pyatv.settings.ProtocolSettings.airplay" href="#pyatv.settings.ProtocolSettings.airplay">airplay</a></code></li>
Expand Down Expand Up @@ -107,7 +116,7 @@ <h1 class="title">Module <code>pyatv.settings</code></h1>
</header>
<section id="section-intro">
<p>Settings for configuring pyatv.</p>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L1-L148" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L1-L168" class="git-link">Browse git</a></div>
</section>
<section>
</section>
Expand All @@ -126,7 +135,7 @@ <h2 class="section-title" id="header-classes">Classes</h2>
<section class="desc"><p>Settings related to AirPlay.</p>
<p>Create a new model by parsing and validating input data from keyword arguments.</p>
<p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L78-L83" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L96-L103" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.v1.main.BaseModel</li>
Expand All @@ -142,6 +151,10 @@ <h3>Class variables</h3>
<dd>
<section class="desc"><p>The type of the None singleton.</p></section>
</dd>
<dt id="pyatv.settings.AirPlaySettings.mrp_tunnel"><code class="name">var <span class="ident">mrp_tunnel</span> -> <a title="pyatv.settings.MrpTunnel" href="#pyatv.settings.MrpTunnel">MrpTunnel</a></code></dt>
<dd>
<section class="desc"><p>The type of the None singleton.</p></section>
</dd>
<dt id="pyatv.settings.AirPlaySettings.password"><code class="name">var <span class="ident">password</span> -> str | None</code></dt>
<dd>
<section class="desc"><p>The type of the None singleton.</p></section>
Expand All @@ -154,7 +167,7 @@ <h3>Class variables</h3>
</code></dt>
<dd>
<section class="desc"><p>AirPlay version to use.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L47-L52" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L47-L57" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>builtins.str</li>
Expand All @@ -164,15 +177,15 @@ <h3>Class variables</h3>
<dl>
<dt id="pyatv.settings.AirPlayVersion.Auto"><code class="name">var <span class="ident">Auto</span> = auto</code></dt>
<dd>
<section class="desc"><p>The type of the None singleton.</p></section>
<section class="desc"><p>Automatically determine what version to use.</p></section>
</dd>
<dt id="pyatv.settings.AirPlayVersion.V1"><code class="name">var <span class="ident">V1</span> = 1</code></dt>
<dd>
<section class="desc"><p>The type of the None singleton.</p></section>
<section class="desc"><p>Use version 1 of AirPlay.</p></section>
</dd>
<dt id="pyatv.settings.AirPlayVersion.V2"><code class="name">var <span class="ident">V2</span> = 2</code></dt>
<dd>
<section class="desc"><p>The type of the None singleton.</p></section>
<section class="desc"><p>Use version 2 of AirPlay.</p></section>
</dd>
</dl>
</dd>
Expand All @@ -184,7 +197,7 @@ <h3>Class variables</h3>
<section class="desc"><p>Settings related to Companion.</p>
<p>Create a new model by parsing and validating input data from keyword arguments.</p>
<p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L86-L90" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L106-L110" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.v1.main.BaseModel</li>
Expand All @@ -210,7 +223,7 @@ <h3>Class variables</h3>
<section class="desc"><p>Settings related to DMAP.</p>
<p>Create a new model by parsing and validating input data from keyword arguments.</p>
<p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L93-L97" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L113-L117" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.v1.main.BaseModel</li>
Expand All @@ -236,7 +249,7 @@ <h3>Class variables</h3>
<section class="desc"><p>Information related settings.</p>
<p>Create a new model by parsing and validating input data from keyword arguments.</p>
<p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L58-L75" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L76-L93" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.v1.main.BaseModel</li>
Expand Down Expand Up @@ -293,7 +306,7 @@ <h3>Static methods</h3>
<section class="desc"><p>Settings related to MRP.</p>
<p>Create a new model by parsing and validating input data from keyword arguments.</p>
<p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L100-L104" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L120-L124" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.v1.main.BaseModel</li>
Expand All @@ -311,6 +324,34 @@ <h3>Class variables</h3>
</dd>
</dl>
</dd>
<dt id="pyatv.settings.MrpTunnel"><code class="flex name class">
<span>class <span class="ident">MrpTunnel</span></span>
<span>(</span><span>*args, **kwds)</span>
</code></dt>
<dd>
<section class="desc"><p>How MRP tunneling over AirPlay is handled.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L60-L70" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>builtins.str</li>
<li>enum.Enum</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="pyatv.settings.MrpTunnel.Auto"><code class="name">var <span class="ident">Auto</span> = auto</code></dt>
<dd>
<section class="desc"><p>Automatically set up MRP tunnel if supported by remote device.</p></section>
</dd>
<dt id="pyatv.settings.MrpTunnel.Disable"><code class="name">var <span class="ident">Disable</span> = disable</code></dt>
<dd>
<section class="desc"><p>Fully disable set up of MRP tunnel.</p></section>
</dd>
<dt id="pyatv.settings.MrpTunnel.Force"><code class="name">var <span class="ident">Force</span> = force</code></dt>
<dd>
<section class="desc"><p>Force set up of MRP tunnel even if remote device does not supports it.</p></section>
</dd>
</dl>
</dd>
<dt id="pyatv.settings.ProtocolSettings"><code class="flex name class">
<span>class <span class="ident">ProtocolSettings</span></span>
<span>(</span><span>**data: Any)</span>
Expand All @@ -319,7 +360,7 @@ <h3>Class variables</h3>
<section class="desc"><p>Container for protocol specific settings.</p>
<p>Create a new model by parsing and validating input data from keyword arguments.</p>
<p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L134-L141" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L154-L161" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.v1.main.BaseModel</li>
Expand Down Expand Up @@ -357,7 +398,7 @@ <h3>Class variables</h3>
<section class="desc"><p>Settings related to RAOP.</p>
<p>Create a new model by parsing and validating input data from keyword arguments.</p>
<p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L107-L131" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L127-L151" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.v1.main.BaseModel</li>
Expand Down Expand Up @@ -403,7 +444,7 @@ <h3>Class variables</h3>
<section class="desc"><p>Settings container class.</p>
<p>Create a new model by parsing and validating input data from keyword arguments.</p>
<p>Raises ValidationError if the input data cannot be parsed to form a valid model.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L144-L148" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L164-L168" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.v1.main.BaseModel</li>
Expand Down
148 changes: 79 additions & 69 deletions pyatv/protocols/airplay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pyatv/scripts/atvremote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions pyatv/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down

0 comments on commit e27164b

Please sign in to comment.