Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions docs/api/pyatv.settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ <h4><code><a title="pyatv.settings.MrpTunnel" href="#pyatv.settings.MrpTunnel">M
<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>
<li><code><a title="pyatv.settings.MrpTunnel.Relay" href="#pyatv.settings.MrpTunnel.Relay">Relay</a></code></li>
</ul>
</li>
<li>
Expand Down Expand Up @@ -118,7 +119,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-L179" 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-L182" class="git-link">Browse git</a></div>
</section>
<section>
</section>
Expand All @@ -139,7 +140,7 @@ <h2 class="section-title" id="header-classes">Classes</h2>
<p>Raises [<code>ValidationError</code>][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.</p>
<p><code>self</code> is explicitly positional-only to allow <code>self</code> as a field name.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L107-L114" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L110-L117" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.main.BaseModel</li>
Expand Down Expand Up @@ -202,7 +203,7 @@ <h3>Class variables</h3>
<p>Raises [<code>ValidationError</code>][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.</p>
<p><code>self</code> is explicitly positional-only to allow <code>self</code> as a field name.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L117-L121" 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.main.BaseModel</li>
Expand All @@ -229,7 +230,7 @@ <h3>Class variables</h3>
<p>Raises [<code>ValidationError</code>][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.</p>
<p><code>self</code> is explicitly positional-only to allow <code>self</code> as a field name.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L124-L128" 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-L131" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.main.BaseModel</li>
Expand All @@ -256,7 +257,7 @@ <h3>Class variables</h3>
<p>Raises [<code>ValidationError</code>][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.</p>
<p><code>self</code> is explicitly positional-only to allow <code>self</code> as a field name.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L78-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#L81-L107" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.main.BaseModel</li>
Expand Down Expand Up @@ -326,7 +327,7 @@ <h3>Static methods</h3>
<p>Raises [<code>ValidationError</code>][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.</p>
<p><code>self</code> is explicitly positional-only to allow <code>self</code> as a field name.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L131-L135" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L134-L138" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.main.BaseModel</li>
Expand All @@ -349,7 +350,7 @@ <h3>Class variables</h3>
</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#L62-L72" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L62-L75" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>builtins.str</li>
Expand All @@ -369,6 +370,10 @@ <h3>Class variables</h3>
<dd>
<section class="desc"><p>Force set up of MRP tunnel even if remote device does not supports it.</p></section>
</dd>
<dt id="pyatv.settings.MrpTunnel.Relay"><code class="name">var <span class="ident">Relay</span> = relay</code></dt>
<dd>
<section class="desc"><p>Use the experimental controlType-1 relay for existing sender playback.</p></section>
</dd>
</dl>
</dd>
<dt id="pyatv.settings.ProtocolSettings"><code class="flex name class">
Expand All @@ -381,7 +386,7 @@ <h3>Class variables</h3>
<p>Raises [<code>ValidationError</code>][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.</p>
<p><code>self</code> is explicitly positional-only to allow <code>self</code> as a field name.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L165-L172" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L168-L175" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.main.BaseModel</li>
Expand Down Expand Up @@ -420,7 +425,7 @@ <h3>Class variables</h3>
<p>Raises [<code>ValidationError</code>][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.</p>
<p><code>self</code> is explicitly positional-only to allow <code>self</code> as a field name.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L138-L162" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L141-L165" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.main.BaseModel</li>
Expand Down Expand Up @@ -467,7 +472,7 @@ <h3>Class variables</h3>
<p>Raises [<code>ValidationError</code>][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.</p>
<p><code>self</code> is explicitly positional-only to allow <code>self</code> as a field name.</p></section>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L175-L179" class="git-link">Browse git</a></div>
<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/settings.py#L178-L182" class="git-link">Browse git</a></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>pydantic.main.BaseModel</li>
Expand Down
76 changes: 75 additions & 1 deletion docs/development/control.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,80 @@ link_group: development
---
# Control

## Experimental AirPlay receiver relay

AirPlay 2 receivers can relay an existing sender's playback information and
commands. Opt into this experimental mode with `MrpTunnel.Relay`; automatic
selection and the existing AirPlay tunnel are unchanged. Keep the audio sender
playing directly to the receiver. Connecting as a relay does not start audio.

```python
import asyncio
import pyatv
from pyatv.const import Protocol
from pyatv.settings import MrpTunnel
from pyatv.storage.memory_storage import MemoryStorage

async def main():
loop = asyncio.get_running_loop()
storage = MemoryStorage()
devices = await pyatv.scan(loop, protocol=Protocol.AirPlay, storage=storage)
config = next(device for device in devices if device.name == "Receiver")
settings = await storage.get_settings(config)
settings.protocols.airplay.mrp_tunnel = MrpTunnel.Relay
atv = await pyatv.connect(config, loop, storage=storage)
try:
print(await atv.metadata.playing())
artwork = await atv.metadata.artwork(width=512)
if artwork:
print(artwork.mimetype, len(artwork.bytes))
await atv.remote_control.pause()
await atv.remote_control.play()
await atv.remote_control.next()
await atv.remote_control.previous()
await atv.remote_control.set_position(90) # seconds
await atv.audio.set_volume(50) # percent, selected receiver
finally:
await asyncio.gather(*atv.close())

asyncio.run(main())
```

Each playback call above changes the active sender. Use only the calls you need.
The [relay example](https://github.com/postlund/pyatv/blob/master/examples/airplay_relay.py)
discovers a named receiver and performs one action (default: status):

```shell
python -m examples.airplay_relay "Receiver"
python -m examples.airplay_relay "Receiver" pause
python -m examples.airplay_relay "Receiver" seek 90
python -m examples.airplay_relay "Receiver" volume 50
```

Normal metadata and push updater interfaces report the globally selected
client/player. `metadata.artwork()` reuses the existing artwork retrieval and
cache; it returns `None` when no artwork is available. Requested image dimensions
are a hint, and the sender may return a different size. Playback commands include
that player's complete path and check sender rejection responses. A group shares playback commands; volume targets the
selected receiver's reported output-device UID. To change all reported group
members explicitly, iterate over `atv.audio.output_devices` and pass each member
to `await atv.audio.set_volume(50, member)`. Volume calls wait for a matching
receiver update; a control socket acknowledgment alone does not confirm volume.

This initial implementation supports transient AirPlay pairing and type-130,
controlType-1 streams without a dedicated dataPort. HomeKit authentication,
receiver compatibility beyond the reference, and modifying group membership
remain unverified. Available playback actions depend on the sender's capabilities.
TV navigation and power controls are not provided by this mode.

The transport authenticates its own encrypted AirPlay session, requests an auth
ticket, negotiates the control-only and remote streams, and subscribes over
binary-plist `/command` requests. It uses the event channel for protobuf replies
and updates, sends periodic feedback, and tears down only its own control session.
Existing Media Remote message/state code is reused internally; no standalone MRP
service or audio data socket is opened. No packet interception or sender keys are
required.

Controlling a device is done with the remote control interface,
{% include api i="interface.RemoteControl" %}. It allows you navigate the menus and
change playback (play, pause, etc.).
Expand Down Expand Up @@ -53,4 +127,4 @@ await rc.menu(action=InputAction.Hold)
await rc.home(action=InputAction.DoubleTap)
```

All input actions are specified in {% include api i="const.InputAction" %}.
All input actions are specified in {% include api i="const.InputAction" %}.
52 changes: 52 additions & 0 deletions examples/airplay_relay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Control existing AirPlay playback using pyatv's public interfaces."""

import argparse
import asyncio

import pyatv
from pyatv.const import Protocol
from pyatv.settings import MrpTunnel
from pyatv.storage.memory_storage import MemoryStorage


async def main() -> None:
"""Discover a receiver, opt into its relay, and perform one requested action."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("name", help="Exact name of the AirPlay receiver")
parser.add_argument(
"action",
nargs="?",
default="status",
choices=["status", "play", "pause", "next", "previous", "seek", "volume"],
)
parser.add_argument(
"value", nargs="?", type=float, help="Seconds or volume (0–100)"
)
args = parser.parse_args()
if args.action in ("seek", "volume") and args.value is None:
parser.error("seek and volume require a value")

loop = asyncio.get_running_loop()
storage = MemoryStorage()
devices = await pyatv.scan(loop, protocol=Protocol.AirPlay, storage=storage)
config = next((device for device in devices if device.name == args.name), None)
if config is None:
print(f"Receiver not found: {args.name}")
return
settings = await storage.get_settings(config)
settings.protocols.airplay.mrp_tunnel = MrpTunnel.Relay
atv = await pyatv.connect(config, loop, storage=storage)
try:
print(await atv.metadata.playing())
if args.action == "seek":
await atv.remote_control.set_position(args.value)
elif args.action == "volume":
await atv.audio.set_volume(args.value)
elif args.action != "status":
await getattr(atv.remote_control, args.action)()
finally:
await asyncio.gather(*atv.close())


if __name__ == "__main__":
asyncio.run(main())
3 changes: 3 additions & 0 deletions pyatv/protocols/airplay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
Stream,
)
from pyatv.protocols import mrp
from pyatv.protocols.airplay import relay
from pyatv.protocols.airplay.ap2_session import AP2Session
from pyatv.protocols.airplay.auth import extract_credentials
from pyatv.protocols.airplay.mrp_connection import AirPlayMrpConnection
Expand Down Expand Up @@ -375,6 +376,8 @@ def _device_info() -> Dict[str, Any]:

if mrp_tunnel == MrpTunnel.Disable:
_LOGGER.debug("Remote control tunnel disabled by setting")
elif mrp_tunnel == MrpTunnel.Relay:
yield relay.setup(core, credentials)
elif mrp_tunnel == MrpTunnel.Force:
_LOGGER.debug("Remote control channel is supported (forced)")
yield _create_mrp_tunnel_data(core, credentials)
Expand Down
Loading