From d6c9ac906fede2ca848b9b8db1e8717bea325ddb Mon Sep 17 00:00:00 2001 From: Jeremy LaCivita Date: Thu, 8 Jan 2026 22:37:56 -0500 Subject: [PATCH 1/3] Detect playbackState changes --- docs/documentation/protocols.md | 32 ++-- pyatv/protocols/airplay/ap2_session.py | 4 +- pyatv/protocols/airplay/auth/hap.py | 88 ++++++--- pyatv/protocols/airplay/auth/legacy.py | 4 +- pyatv/protocols/airplay/channels.py | 33 +++- pyatv/protocols/airplay/player.py | 65 +++---- pyatv/protocols/raop/protocols/__init__.py | 3 + pyatv/protocols/raop/protocols/airplayv2.py | 188 ++++++++++++-------- pyatv/scripts/atvproxy.py | 27 ++- pyatv/support/http.py | 48 ++++- pyatv/support/rtsp.py | 4 +- 11 files changed, 320 insertions(+), 176 deletions(-) diff --git a/docs/documentation/protocols.md b/docs/documentation/protocols.md index ec6ceb1d0..aa42902c5 100644 --- a/docs/documentation/protocols.md +++ b/docs/documentation/protocols.md @@ -1205,7 +1205,7 @@ AirPlay uses two services, one for audio and one for video. They are described h | osvers | 14.5 | Operating system version | pi | UUID4 | Group ID | vv | 2 | ? -| srcvers | 540.31.41 | AirPlay version +| srcvers | 870.14.1 | AirPlay version | psi | UUID4 | Public AirPlay Pairing Identifier | gid | UUID4 | Group UUID | pk | UUID4 | Public key @@ -1234,7 +1234,7 @@ Sender asks receiver what methods it supports: ```raw OPTIONS * RTSP/1.0 CSeq: 0 -nUser-Agent: AirPlay/540.31 +nUser-Agent: AirPlay/870.14 DACP-ID: A851074254310A45 Active-Remote: 4019753970 Client-Instance: A851074254310A45 @@ -1246,7 +1246,7 @@ RTSP/1.0 200 OK Date: Tue, 11 May 2021 17:35:10 GMT Content-Length: 0 Public: ANNOUNCE, SETUP, RECORD, PAUSE, FLUSH, TEARDOWN, OPTIONS, GET_PARAMETER, SET_PARAMETER, POST, GET, PUT -Server: AirTunes/540.31.41 +Server: AirTunes/870.14.1 CSeq: 0 ``` @@ -1258,7 +1258,7 @@ Sender tells the receiver about properties for an upcoming stream. ```raw ANNOUNCE rtsp://10.0.10.254/4018537194 RTSP/1.0 CSeq: 0 -User-Agent: AirPlay/540.31 +User-Agent: AirPlay/870.14 DACP-ID: 9D881F7AED72DB4A Active-Remote: 3630929274 Client-Instance: 9D881F7AED72DB4A @@ -1287,7 +1287,7 @@ Some observations (might not be true): RTSP/1.0 200 OK Date: Tue, 11 May 2021 17:25:54 GMT Content-Length: 0 -Server: AirTunes/540.31.41 +Server: AirTunes/870.14.1 CSeq: 0 ``` @@ -1305,7 +1305,7 @@ Sender requests initialization of a (Airplay v1) session (but does not start it) ```raw SETUP rtsp://10.0.10.254/1085946124 RTSP/1.0 CSeq: 2 -User-Agent: AirPlay/540.31 +User-Agent: AirPlay/870.14 DACP-ID: A851074254310A45 Active-Remote: 4019753970 Client-Instance: A851074254310A45 @@ -1320,7 +1320,7 @@ Content-Length: 0 Transport: RTP/AVP/UDP;unicast;mode=record;server_port=55801;control_port=50367;timing_port=0 Session: 1 Audio-Jack-Status: connected -Server: AirTunes/540.31.41 +Server: AirTunes/870.14.1 CSeq: 2 ``` @@ -1347,7 +1347,7 @@ randomized. ```raw RECORD rtsp://10.0.10.254/1085946124 RTSP/1.0 CSeq: 6 -User-Agent: AirPlay/540.31 +User-Agent: AirPlay/870.14 DACP-ID: A851074254310A45 Active-Remote: 4019753970 Client-Instance: A851074254310A45 @@ -1362,7 +1362,7 @@ RTSP/1.0 200 OK Date: Tue, 11 May 2021 07:35:11 GMT Content-Length: 0 Audio-Latency: 3035 -Server: AirTunes/540.31.41 +Server: AirTunes/870.14.1 CSeq: 6 ``` @@ -1374,7 +1374,7 @@ Requests to flush the receivers buffer and pause/stop what is playing. ```raw FLUSH rtsp://10.0.10.254/1085946124 RTSP/1.0 CSeq: 7 -User-Agent: AirPlay/540.31 +User-Agent: AirPlay/870.14 DACP-ID: A851074254310A45 Active-Remote: 4019753970 Client-Instance: A851074254310A45 @@ -1385,7 +1385,7 @@ Client-Instance: A851074254310A45 RTSP/1.0 200 OK Date: Tue, 11 May 2021 17:35:11 GMT Content-Length: 0 -Server: AirTunes/540.31.41 +Server: AirTunes/870.14.1 CSeq: 7 ``` @@ -1397,7 +1397,7 @@ End the active session. ```raw TEARDOWN rtsp://10.0.10.254/1085946124 RTSP/1.0 CSeq: 8 -User-Agent: AirPlay/540.31 +User-Agent: AirPlay/870.14 DACP-ID: A851074254310A45 Active-Remote: 4019753970 Client-Instance: A851074254310A45 @@ -1408,7 +1408,7 @@ Client-Instance: A851074254310A45 RTSP/1.0 200 OK Date: Tue, 11 May 2021 17:35:19 GMT Content-Length: 0 -Server: AirTunes/540.31.41 +Server: AirTunes/870.14.1 CSeq: 8 ``` @@ -1420,7 +1420,7 @@ Change a parameter, e.g. metadata or progress, on the receiver. ```raw SET_PARAMETER rtsp://10.0.10.254/1085946124 RTSP/1.0 CSeq: 3 -User-Agent: AirPlay/540.31 +User-Agent: AirPlay/870.14 DACP-ID: A851074254310A45 Active-Remote: 4019753970 Client-Instance: A851074254310A45 @@ -1435,7 +1435,7 @@ volume: -20 RTSP/1.0 200 OK Date: Tue, 11 May 2021 17:35:11 GMT Content-Length: 0 -Server: AirTunes/540.31.41 +Server: AirTunes/870.14.1 CSeq: 3 ``` @@ -1463,7 +1463,7 @@ from owntone [here](https://github.com/owntone/owntone-server/blob/c1db4d914f5cd ```raw POST /auth-setup RTSP/1.0 CSeq: 0 -User-Agent: AirPlay/540.31 +User-Agent: AirPlay/870.14 DACP-ID: BFAA2A9155BD093C Active-Remote: 347218209 Client-Instance: BFAA2A9155BD093C diff --git a/pyatv/protocols/airplay/ap2_session.py b/pyatv/protocols/airplay/ap2_session.py index aa5f1408f..7a36f6536 100644 --- a/pyatv/protocols/airplay/ap2_session.py +++ b/pyatv/protocols/airplay/ap2_session.py @@ -19,7 +19,7 @@ from pyatv.protocols.airplay.auth import verify_connection from pyatv.protocols.airplay.channels import DataStreamChannel, EventChannel from pyatv.settings import InfoSettings -from pyatv.support.http import HttpConnection, decode_bplist_from_body, http_connect +from pyatv.support.http import HttpConnection, decode_plist_body, http_connect from pyatv.support.rtsp import RtspSession from pyatv.support.state_producer import StateProducer @@ -110,7 +110,7 @@ async def _send_feedback(message: Optional[Any]) -> None: async def _setup(self, body: Dict[str, Any]) -> Dict[str, Any]: assert self.rtsp resp = await self.rtsp.setup(body=body) - return decode_bplist_from_body(resp) + return decode_plist_body(resp.body) async def _setup_event_channel(self, address: str) -> None: if self.verifier is None: diff --git a/pyatv/protocols/airplay/auth/hap.py b/pyatv/protocols/airplay/auth/hap.py index 8e6ab4a57..1a3ab4226 100644 --- a/pyatv/protocols/airplay/auth/hap.py +++ b/pyatv/protocols/airplay/auth/hap.py @@ -13,18 +13,17 @@ from pyatv.auth.hap_srp import SRPAuthHandler from pyatv.exceptions import InvalidResponseError from pyatv.support import log_binary -from pyatv.support.http import HttpConnection, HttpResponse +from pyatv.support.http import HttpConnection, HttpResponse, HttpRequest _LOGGER = logging.getLogger(__name__) _AIRPLAY_HEADERS = { - "User-Agent": "AirPlay/320.20", + "User-Agent": "AirPlay/870.14.1", "Connection": "keep-alive", "X-Apple-HKP": 3, - "Content-Type": "application/octet-stream", + "Content-Type": "application/octet-stream" } - def _get_pairing_data(resp: HttpResponse): if not isinstance(resp.body, bytes): raise InvalidResponseError(f"got unexpected response: {resp.body}") @@ -107,45 +106,82 @@ def __init__( self.http = http self.srp = auth_handler self.credentials = credentials + self.pairing_data = None + self._sequence = 0 async def verify_credentials(self) -> bool: - """Verify if device is allowed to use AirPlau.""" + """Verify if device is allowed to use AirPlay.""" + await self.verify_credentials_seq1() + await self.verify_credentials_seq2() - _, public_key = self.srp.initialize() + return True + + def sequence(self) -> int: + return self._sequence - resp = await self._send( - { - hap_tlv8.TlvValue.SeqNo: b"\x01", - hap_tlv8.TlvValue.PublicKey: public_key, - } - ) + async def verify_credentials_seq1(self, request:HttpRequest=None): + _, public_key = self.srp.initialize() - pairing_data = _get_pairing_data(resp) - session_pub_key = pairing_data[hap_tlv8.TlvValue.PublicKey] - encrypted = pairing_data[hap_tlv8.TlvValue.EncryptedData] + # copy data from template request + data = hap_tlv8.read_tlv( + request.body + if isinstance(request.body, bytes) + else request.body.encode("utf-8") + ) if request else {} + + # override critical values + data[hap_tlv8.TlvValue.SeqNo] = b"\x01" +# data[hap_tlv8.TlvValue.Method] = b"\x07" + data[hap_tlv8.TlvValue.PublicKey] = public_key + + response = await self._send(data, copy(request.headers if request else _AIRPLAY_HEADERS)) + + self.pairing_data = _get_pairing_data(response) + + async def verify_credentials_seq2(self, request:HttpRequest=None): + session_pub_key = self.pairing_data[hap_tlv8.TlvValue.PublicKey] + encrypted = self.pairing_data[hap_tlv8.TlvValue.EncryptedData] log_binary(_LOGGER, "Device", Public=self.credentials.ltpk, Encrypted=encrypted) encrypted_data = self.srp.verify1(self.credentials, session_pub_key, encrypted) - await self._send( - { - hap_tlv8.TlvValue.SeqNo: b"\x03", - hap_tlv8.TlvValue.EncryptedData: encrypted_data, - } - ) - # TODO: check status code + # copy data from template request + data = hap_tlv8.read_tlv( + request.body + if isinstance(request.body, bytes) + else request.body.encode("utf-8") + ) if request else {} + + data[hap_tlv8.TlvValue.SeqNo] = b"\x03" + data[hap_tlv8.TlvValue.EncryptedData] = encrypted_data + + response = await self._send(data, copy(request.headers if request else _AIRPLAY_HEADERS)) + + if response.code != 200: + raise (f"HAP Verification Sequence 2 failed with %s", response.code) return True - async def _send(self, data: Dict[Any, Any]) -> HttpResponse: - headers = copy(_AIRPLAY_HEADERS) + async def _send(self, data: Dict[Any, Any], headers=copy(_AIRPLAY_HEADERS)) -> HttpResponse: +# data[hap_tlv8.TlvValue.Method] = b"\x07" + body = hap_tlv8.write_tlv(data) headers["Content-Type"] = "application/octet-stream" - return await self.http.post( - "/pair-verify", body=hap_tlv8.write_tlv(data), headers=headers + headers["Content-Length"] = len(body) + + self._sequence = data[hap_tlv8.TlvValue.SeqNo][0] + + resp = await self.http.post( + "/pair-verify", body=body, headers=headers ) + if (resp.code == 200): + self._sequence = hap_tlv8.read_tlv(resp.body)[hap_tlv8.TlvValue.SeqNo][0] + + return resp + def encryption_keys( self, salt: str, output_info: str, input_info: str ) -> Tuple[bytes, bytes]: """Return derived encryption keys.""" + return self.srp.verify2(salt, output_info, input_info) diff --git a/pyatv/protocols/airplay/auth/legacy.py b/pyatv/protocols/airplay/auth/legacy.py index cf582be53..c99941ecb 100755 --- a/pyatv/protocols/airplay/auth/legacy.py +++ b/pyatv/protocols/airplay/auth/legacy.py @@ -12,7 +12,7 @@ PairVerifyProcedure, ) from pyatv.protocols.airplay.srp import LegacySRPAuthHandler -from pyatv.support.http import HttpConnection, HttpResponse, decode_bplist_from_body +from pyatv.support.http import HttpConnection, HttpResponse, decode_plist_body _LOGGER = logging.getLogger(__name__) @@ -53,7 +53,7 @@ async def finish_pairing( ) self.srp.step1(client_id, pin_code) resp = await self._send_plist(method="pin", user=client_id) - body = decode_bplist_from_body(resp) + body = decode_plist_body(resp.body) if not isinstance(body, dict): raise exceptions.ProtocolError(f"exoected dict, got {type(body).__name__}") diff --git a/pyatv/protocols/airplay/channels.py b/pyatv/protocols/airplay/channels.py index a5730293f..bd49def86 100644 --- a/pyatv/protocols/airplay/channels.py +++ b/pyatv/protocols/airplay/channels.py @@ -6,7 +6,7 @@ from abc import ABC import logging from random import randrange -from typing import Any, List, NamedTuple, Optional, Tuple +from typing import Any, List, NamedTuple, Optional, Tuple, Dict from pyatv.auth.hap_channel import AbstractHAPChannel from pyatv.protocols.airplay.utils import decode_plist_body, encode_plist_body @@ -21,6 +21,7 @@ ) from pyatv.support.packet import defpacket from pyatv.support.variant import read_variant, write_variant +import asyncio _LOGGER = logging.getLogger(__name__) @@ -60,6 +61,23 @@ def parse_response(data: bytes) -> Tuple[Optional[HttpResponse], bytes, bytes]: class EventChannel(BaseEventChannel): """Connection used to handle the event channel.""" + def __init__(self, output_key, input_key): + super().__init__(output_key, input_key) + self._requests = {} + self._responses = {} + self._listener = None + + async def responseFor(self, messageId: int) -> Dict: + event = asyncio.Event() + self._requests[str(messageId)] = event + await event.wait() + response = self._responses[str(messageId)] + del self._responses[str(messageId)] + return response + + def listener(self, listener): + self._listener = listener + def handle_received(self) -> None: """Handle received data that was put in buffer.""" self.buffer: bytes @@ -72,6 +90,19 @@ def handle_received(self) -> None: _LOGGER.debug("Got message on event channel: %s", request) + plist = decode_plist_body(request.body) + if plist and 'params' in plist and 'data' in plist['params']: + plist = decode_plist_body(plist['params']['data']) + if plist and 'kind' in plist: + if plist['kind'] == "response": + ID = str(plist["messageID"]) + if ID in self._requests: + self._responses[ID] = plist + self._requests[ID].set() + elif plist["type"] == "playbackState": + # Update state + self._listener(plist) + # Send a positive response to satisfy the other end of the channel headers = { "Content-Length": "0", diff --git a/pyatv/protocols/airplay/player.py b/pyatv/protocols/airplay/player.py index e2c073cb0..b0622a90f 100644 --- a/pyatv/protocols/airplay/player.py +++ b/pyatv/protocols/airplay/player.py @@ -3,10 +3,11 @@ import asyncio from contextlib import asynccontextmanager import logging +import time +import sys from pyatv import exceptions from pyatv.protocols.raop.protocols import StreamProtocol, TimingServer -from pyatv.support.http import decode_bplist_from_body from pyatv.support.rtsp import RtspSession _LOGGER = logging.getLogger(__name__) @@ -14,10 +15,10 @@ PLAY_RETRIES = 3 WAIT_RETRIES = 5 HEADERS = { - "User-Agent": "AirPlay/550.10", + "User-Agent": "AirPlay/870.14.1", "Content-Type": "application/x-apple-binary-plist", "X-Apple-ProtocolVersion": "1", - "X-Apple-Stream-ID": "1", + "X-Apple-StreamID": "1", } @@ -46,24 +47,23 @@ async def play_url(self, url: str, position: float = 0) -> None: retry = 0 async with timing_server(self.rtsp) as server: - while retry < PLAY_RETRIES: - _LOGGER.debug("Starting to play %s", url) - resp = await self.stream_protocol.play_url(server.port, url, position) + # Sometimes AirPlay fails with "Internal Server Error", we + # apply a "lets try again"-approach to that + while retry < PLAY_RETRIES: + _LOGGER.info("Starting to play %s", url) - # Sometimes AirPlay fails with "Internal Server Error", we - # apply a "lets try again"-approach to that - if resp.code == 500: + try: + await self.stream_protocol.play_url(server.port, url, position) + except Exception as e: retry += 1 - _LOGGER.debug( + _LOGGER.warning( "Failed to stream %s, retry %d of %d", url, retry, PLAY_RETRIES ) await asyncio.sleep(1.0) continue - - # TODO: Should be more fine-grained - if 400 <= resp.code < 600: - raise exceptions.AuthenticationError(f"status code: {resp.code}") + # TODO: retry only on 500s, raise exception on 400 to 599 and 501 to 600 + # TODO: is this even needed anymore? If so, need to wrap HTTP codes in an exception from airplayv2.py await self._wait_for_media_to_end() return @@ -72,6 +72,7 @@ async def play_url(self, url: str, position: float = 0) -> None: # Poll playback-info to find out if something is playing. It might take # some time until the media starts playing, give it 5 seconds (attempts) + async def _wait_for_media_to_end(self) -> None: attempts: int = WAIT_RETRIES video_started: bool = False @@ -80,39 +81,19 @@ async def _wait_for_media_to_end(self) -> None: # In some cases this call will fail if video was stopped by the sender, # e.g. stopping video via remote control. For now, handle this gracefully # by not spewing an exception. - try: - resp = await self.rtsp.connection.get("/playback-info") - except (RuntimeError, exceptions.ConnectionLostError): - _LOGGER.debug("Connection was lost, assuming video playback stopped") - break + state = self.stream_protocol.playbackState() - _LOGGER.debug("Playback-info: %s", resp) + _LOGGER.debug(f"Playback-info: {state}") - if resp.body: - parsed = decode_bplist_from_body(resp) - else: - parsed = {} - _LOGGER.debug("Got playback-info response without content") - - # In case we got an error, abort with that here - if "error" in parsed: - code = parsed["error"].get("code", "unknown") - domain = parsed["error"].get("domain", "unknown domain") - raise exceptions.PlaybackError( - f"got error {code} ({domain}) when playing video" - ) - - # duration is only available if something is playing - if "duration" in parsed: + if state == "playing": video_started = True - attempts = -1 - else: - video_started = False - if attempts >= 0: - attempts -= 1 + + if state == "stopped": + _LOGGER.info("media has stopped") + break if not video_started and attempts < 0: - _LOGGER.debug("media playback ended") + _LOGGER.warning("media failed to start") break await asyncio.sleep(1) diff --git a/pyatv/protocols/raop/protocols/__init__.py b/pyatv/protocols/raop/protocols/__init__.py index fbaefd446..f8656eeab 100644 --- a/pyatv/protocols/raop/protocols/__init__.py +++ b/pyatv/protocols/raop/protocols/__init__.py @@ -98,6 +98,9 @@ async def send_audio_packet( async def play_url(self, timing_server_port: int, url: str, position: float = 0.0): """Play media from a URL.""" + @abstractmethod + async def playbackState(self) -> str: + """Play media from a URL.""" class TimingServer(asyncio.Protocol): """Basic timing server responding to timing requests.""" diff --git a/pyatv/protocols/raop/protocols/airplayv2.py b/pyatv/protocols/raop/protocols/airplayv2.py index 3d61a34e0..a0fd02ffd 100644 --- a/pyatv/protocols/raop/protocols/airplayv2.py +++ b/pyatv/protocols/raop/protocols/airplayv2.py @@ -3,7 +3,7 @@ import asyncio import logging import plistlib -from typing import Optional, Tuple +from typing import Optional, Tuple, Dict from uuid import uuid4 from pyatv import exceptions @@ -13,7 +13,7 @@ from pyatv.protocols.airplay.channels import EventChannel from pyatv.protocols.raop.protocols import StreamContext, StreamProtocol from pyatv.support.chacha20 import Chacha20Cipher, Chacha20Cipher8byteNonce -from pyatv.support.http import decode_bplist_from_body +from pyatv.support.http import decode_plist_body from pyatv.support.rtsp import RtspSession _LOGGER = logging.getLogger(__name__) @@ -24,30 +24,36 @@ FEEDBACK_INTERVAL = 2.0 # Seconds +SESSION_ID = str(uuid4()).upper() + HEADERS = { - "User-Agent": "AirPlay/550.10", + "User-Agent": "AirPlay/870.14.1", "Content-Type": "application/x-apple-binary-plist", "X-Apple-ProtocolVersion": "1", - "X-Apple-Session-ID": str(uuid4()).lower(), - "X-Apple-Stream-ID": "1", + "X-Apple-Session-ID": SESSION_ID, + "X-Apple-StreamID": "1", + "CSeq": "1" } - class AirPlayV2(StreamProtocol): - """Stream protocol used for AirPlay v1 support.""" + """Stream protocol used for AirPlay v2 support.""" def __init__(self, context: StreamContext, rtsp: RtspSession) -> None: """Initialize a new AirPlayV2 instance.""" super().__init__() self.context = context self.rtsp = rtsp - self.event_channel: Optional[asyncio.BaseTransport] = None + self.event_transport: Optional[asyncio.BaseTransport] = None self._verifier: Optional[PairVerifyProcedure] = None self._cipher: Optional[Chacha20Cipher] = None self._feedback_task: Optional[asyncio.Task] = None - + self._messageID = 1 + self._playbackState = None self.uuid = str(uuid4()) + def _playbackStateListener(self, info): + self._playbackState = info + async def _setup_base(self, timing_server_port: int) -> None: self._verifier = await verify_connection( self.context.credentials, self.rtsp.connection @@ -56,7 +62,8 @@ async def _setup_base(self, timing_server_port: int) -> None: setup_resp = await self.rtsp.setup( body={ "deviceID": "AA:BB:CC:DD:EE:FF", - "sessionUUID": str(uuid4()).upper(), + "sessionUUID": SESSION_ID, + "sessionCorrelationUUID": str(uuid4()).upper(), "timingPort": timing_server_port, "timingProtocol": "NTP", "isMultiSelectAirPlay": True, @@ -72,8 +79,7 @@ async def _setup_base(self, timing_server_port: int) -> None: "statsCollectionEnabled": False, } ) - resp = decode_bplist_from_body(setup_resp) - _LOGGER.debug("Setup response body: %s", resp) + resp = decode_plist_body(setup_resp.body) event_port = resp.get("eventPort", 0) @@ -85,7 +91,7 @@ async def _setup_base(self, timing_server_port: int) -> None: transport = None while transport is None: try: - transport, _ = await setup_channel( + transport, channel = await setup_channel( EventChannel, self._verifier, self.rtsp.connection.remote_ip, @@ -94,15 +100,17 @@ async def _setup_base(self, timing_server_port: int) -> None: EVENTS_READ_INFO, EVENTS_WRITE_INFO, ) - except ConnectionRefusedError: + except (ConnectionRefusedError, OSError): retries -= 1 if retries == 0: raise - _LOGGER.debug("Connect failed, retrying") + _LOGGER.warning("Connect failed, retrying") await asyncio.sleep(1.0) - self.event_channel = transport + self.event_transport = transport + self.event_channel = channel + self.event_channel.listener(self._playbackStateListener) async def setup(self, timing_server_port: int, control_client_port: int) -> None: """To setup connection prior to starting to stream.""" @@ -145,7 +153,7 @@ async def setup_audio_stream(self, control_client_port: int) -> None: ] } ) - resp = decode_bplist_from_body(setup_resp) + resp = decode_plist_body(setup_resp.body) _LOGGER.debug("Setup stream response: %s", resp) stream = resp["streams"][0] @@ -160,9 +168,9 @@ def teardown(self) -> None: if self._feedback_task: self._feedback_task.cancel() self._feedback_task = None - if self.event_channel: - self.event_channel.close() - self.event_channel = None + if self.event_transport: + self.event_transport.close() + self.event_transport = None async def start_feedback(self) -> None: """Start to send feedback (if supported and required).""" @@ -173,12 +181,12 @@ async def _feedback_task_loop(self) -> None: _LOGGER.debug("Starting feedback task") # TODO: Better end condition here to not risk infinite runs? while True: + await asyncio.sleep(FEEDBACK_INTERVAL) try: await self.rtsp.feedback() except Exception as ex: # Treat feedback as "best effort" and don't raise any errors _LOGGER.debug("Feedback failed: %s", ex) - await asyncio.sleep(FEEDBACK_INTERVAL) async def send_audio_packet( self, transport: asyncio.DatagramTransport, rtp_header: bytes, audio: bytes @@ -207,67 +215,101 @@ async def send_audio_packet( return self.context.rtpseq, packet - async def play_url(self, timing_server_port: int, url: str, position: float = 0.0): - """Play media from a URL.""" - await self._setup_base(timing_server_port) - await self.start_feedback() - await self.rtsp.record() - + async def send_command(self, data): # Most fields are not needed here, but keeping them for reference body = { - "Content-Location": url, - "Start-Position-Seconds": position, - "uuid": self.uuid, - "streamType": 1, - "mediaType": "file", - "mightSupportStorePastisKeyRequests": True, - "playbackRestrictions": 0, - "secureConnectionMs": 22, - "volume": 1.0, - "infoMs": 122, - "connectMs": 18, - "authMs": 0, - "bonjourMs": 0, - "referenceRestrictions": 3, - "SenderMACAddress": "AA:BB:CC:DD:EE:FF", - "model": "iPhone14,3", - "postAuthMs": 0, - "clientBundleID": "dev.pyatv.GPU", - "clientProcName": "dev.pyatv.GPU", - "osBuildVersion": "20G1116", - "rate": 1.0, + "params": { + "data": plistlib.dumps(data, fmt=plistlib.FMT_BINARY, sort_keys=False) + } } - # Actually start the stream - resp = await self.rtsp.connection.post( - "/play", + # Send the command + return await self.rtsp.connection.post( + "/command", headers=HEADERS, body=plistlib.dumps( body, fmt=plistlib.FMT_BINARY # pylint: disable=no-member ), allow_error=True, - ) + ) - # Various commands, most of which are probably not needed for pyatv. Doing them - # anyways, just to be sure things work. Most important command is "/rate" as - # that sets playback rate to 100% (will start paused otherwise). - # TODO: Maybe check some return values? - await self.rtsp.exchange( - "PUT", uri="/setProperty?isInterestedInDateRange", body={"value": True} - ) - await self.rtsp.exchange( - "PUT", uri="/setProperty?actionAtItemEnd", body={"value": 0} - ) - await self.rtsp.exchange("POST", uri="/rate?value=1.000000") - await self.rtsp.exchange( - "PUT", - uri="/setProperty?forwardEndTime", - body={"value": {"flags": 0, "value": 0, "epoch": 0, "timescale": 0}}, - ) - await self.rtsp.exchange( - "PUT", - uri="/setProperty?reverseEndTime", - body={"value": {"flags": 0, "value": 0, "epoch": 0, "timescale": 0}}, - ) + async def setup_url_stream(self) -> dict: + """Setup a new stream used for video over http.""" + if self._verifier is None: + raise exceptions.InvalidStateError("base stream not set up") + setup_resp = await self.rtsp.setup( + body={ + "streams": [ + { + 'clientUUID': '2E0A9FBA-182D-4E04-8A5D-EC018BD8C408', + 'clientTypeUUID': 'A6B27562-B43A-4F2D-B75F-82391E250194', + 'channelID': '36:CB:3F:E1:93:B0-RCS-1', + 'controlType': 1, + 'type': 130 + } + ] + }) + + resp = decode_plist_body(setup_resp.body) + HEADERS["X-Apple-StreamID"] = resp["streams"][0]["streamID"] return resp + + async def play_url(self, timing_server_port: int, url: str, position: float = 0.0) -> int : + """Play media from a URL.""" + if not self._verifier: + await self._setup_base(timing_server_port) + await self.start_feedback() + + await self.rtsp.info() + await self.rtsp.record() + resp = await self.setup_url_stream() + + item = { + "uuid": "30BFEC7B-E49B-47E9-8839-E009D7F9CD7F" + } + + resp = await self.send_command({ + "type": "insertPlayQueueItem", + "item": { + "uuid": item["uuid"], + "mediaType": "file", + "Content-Location": url, + } + }) + + await self.send_command({ + "type": "setProperty", + "value": True, + "property": "isInterestedInDateRange", + "item": item + }) + + await self.send_command({ + "type": "setProperty", + "value": 1, + "property": "actionAtItemEnd" + }) + + await self.send_command({ + "type": "setRate", + "rate": 1.0, + }) + + return self._playbackState + + async def playbackInfo(self) -> Dict: + id = self._messageID + self._messageID += 1 + await self.send_command({'type': 'playbackInfo', 'kind': 'request', 'messageID': id}) + response = await self.event_channel.responseFor(id) + return response + + def playbackState(self) -> Dict: + if self._playbackState: + if 'params' in self._playbackState: + return self._playbackState['params']['playbackState'] + else: + return self._playbackState['name'] + else: + return None diff --git a/pyatv/scripts/atvproxy.py b/pyatv/scripts/atvproxy.py index 22937e157..6f2e825ca 100755 --- a/pyatv/scripts/atvproxy.py +++ b/pyatv/scripts/atvproxy.py @@ -1572,8 +1572,29 @@ def proxy_factory(): _LOGGER.debug("Binding to local address %s", args.local_ip) service_type = "_airplay._tcp.local" - resp = await mdns.unicast(loop, args.remote_ip, [service_type]) - service = next((s for s in resp.services if s.type == service_type), None) + service = None + error = None + try: + resp = await mdns.unicast(loop, args.remote_ip, [service_type]) + service = next((s for s in resp.services if s.type == service_type), None) + + except TimeoutError as e1: + # some non-Apple AirPlay decives don't support unicast mdns + # so try multicast, instead + _LOGGER.warning('Unicast connection failed, attempting Multicast...') + try: + for response in (await mdns.multicast(loop, [service_type])): + for x in response.services: + if str(x.address) == args.remote_ip: + service = x + break + except Exception as e2: + error = e2 + error = e1 + + if not service: + _LOGGER.warning('Connection failed') + return None if not args.remote_port: args.remote_port = service.port @@ -1657,7 +1678,7 @@ async def appstart(loop): # To get logging from pyatv logging.basicConfig( - level=logging.DEBUG, + level=_LOGGER.DEBUG, stream=sys.stdout, datefmt="%Y-%m-%d %H:%M:%S", format="%(asctime)s %(levelname)s [%(name)s]: %(message)s", diff --git a/pyatv/support/http.py b/pyatv/support/http.py index f7d2d74b4..be6698b89 100644 --- a/pyatv/support/http.py +++ b/pyatv/support/http.py @@ -20,6 +20,8 @@ cast, ) +import biplist + from aiohttp import ClientSession, web from aiohttp.web import middleware from requests.structures import CaseInsensitiveDict @@ -218,15 +220,16 @@ def parse_request(request: bytes) -> Tuple[Optional[HttpRequest], bytes]: ) -def decode_bplist_from_body(response: HttpResponse) -> Dict[str, Any]: - """Decode a binary property list in a response.""" - if not isinstance(response.body, (bytes, str)): - raise exceptions.ProtocolError( - f"expected bytes or str but got {type(response.body).__name__}" - ) - - body = response.body - return plistlib.loads(body if isinstance(body, bytes) else body.encode("utf-8")) +def decode_plist_body(body: Union[str, bytes, Dict[Any, Any]]) -> Any: + """Decode a binary plist payload.""" + try: + if isinstance(body, Dict): + return body + return biplist.readPlistFromString(body) +# return plistlib.loads(body if isinstance(body, bytes) else body.encode("utf-8")) +# except plistlib.InvalidFileException: + except (biplist.InvalidPlistException, biplist.NotBinaryPlistException): + return None class ClientSessionManager: @@ -456,6 +459,19 @@ async def send_and_receive( self.transport.write(self.send_processor(output)) + # print(f"> {method} {uri} {protocol} {content_type}") + # for h in headers: + # print(f" {h}: {headers[h]}") + + # if body: + # plist = decode_plist_body(body) + # if plist: + # print(f"\n body: {plist}") + # else: + # print(f"\n body: {body}") + + # print(" ") + pending_request = HttpConnection.PendingRequest(event=asyncio.Event()) self._requests.appendleft(pending_request) try: @@ -479,6 +495,20 @@ async def send_and_receive( _LOGGER.debug("Got %s response: %s:", response.protocol, response) + # print(f"< {response.code} {method} {uri} {content_type}") + # for h in response.headers: + # print(f" {h}: {response.headers[h]}") + + # if response.body: + # plist = decode_plist_body(response.body) + # if plist: + # print(f"\n body: {plist}") + # else: + # print(f"\n body: {response.body}") + + # print(" ") + + if response.code == 403: raise exceptions.AuthenticationError("not authenticated") diff --git a/pyatv/support/rtsp.py b/pyatv/support/rtsp.py index 56d7c6ed4..13b9c2903 100644 --- a/pyatv/support/rtsp.py +++ b/pyatv/support/rtsp.py @@ -13,7 +13,7 @@ from pyatv.protocols.dmap import tags from pyatv.support import async_timeout -from pyatv.support.http import HttpConnection, HttpResponse, decode_bplist_from_body +from pyatv.support.http import HttpConnection, HttpResponse, decode_plist_body from pyatv.support.metadata import MediaMetadata _LOGGER = logging.getLogger(__name__) @@ -107,7 +107,7 @@ async def info(self) -> Dict[str, object]: _LOGGER.debug("Device does not support /info") return {} - return decode_bplist_from_body(device_info) + return decode_plist_body(device_info.body) async def auth_setup(self) -> HttpResponse: """Send auth-setup message.""" From 435a6fa157cfe32e38a2a03cda8e0cf359692acf Mon Sep 17 00:00:00 2001 From: Jeremy LaCivita Date: Sat, 21 Mar 2026 20:50:27 -0400 Subject: [PATCH 2/3] Re-enabled some logs --- pyatv/support/http.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/pyatv/support/http.py b/pyatv/support/http.py index be6698b89..5e8638534 100644 --- a/pyatv/support/http.py +++ b/pyatv/support/http.py @@ -459,18 +459,18 @@ async def send_and_receive( self.transport.write(self.send_processor(output)) - # print(f"> {method} {uri} {protocol} {content_type}") - # for h in headers: - # print(f" {h}: {headers[h]}") - - # if body: - # plist = decode_plist_body(body) - # if plist: - # print(f"\n body: {plist}") - # else: - # print(f"\n body: {body}") + print(f"> {method} {uri} {protocol} {content_type}") + for h in headers: + print(f" {h}: {headers[h]}") + + if body: + plist = decode_plist_body(body) + if plist: + print(f"\n body: {plist}") + else: + print(f"\n body: {body}") - # print(" ") + print(" ") pending_request = HttpConnection.PendingRequest(event=asyncio.Event()) self._requests.appendleft(pending_request) @@ -495,18 +495,18 @@ async def send_and_receive( _LOGGER.debug("Got %s response: %s:", response.protocol, response) - # print(f"< {response.code} {method} {uri} {content_type}") - # for h in response.headers: - # print(f" {h}: {response.headers[h]}") + print(f"< {response.code} {method} {uri} {content_type}") + for h in response.headers: + print(f" {h}: {response.headers[h]}") - # if response.body: - # plist = decode_plist_body(response.body) - # if plist: - # print(f"\n body: {plist}") - # else: - # print(f"\n body: {response.body}") + if response.body: + plist = decode_plist_body(response.body) + if plist: + print(f"\n body: {plist}") + else: + print(f"\n body: {response.body}") - # print(" ") + print(" ") if response.code == 403: From 8848ad3fd9ae46b8eb733bfc667b536a28f04c5a Mon Sep 17 00:00:00 2001 From: Jeremy LaCivita Date: Sat, 25 Apr 2026 11:30:59 -0400 Subject: [PATCH 3/3] minor requirements updates --- base_versions.txt | 1 + requirements/requirements.txt | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/base_versions.txt b/base_versions.txt index 34c8360e1..3a651864e 100644 --- a/base_versions.txt +++ b/base_versions.txt @@ -1,4 +1,5 @@ aiohttp==3.8.3,<5 +biplist==1.0.3 async-timeout==4.0.2;python_version<'3.11' cryptography==44.0.1 chacha20poly1305-reuseable==0.13.2 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 9b6594d3e..464cfcbd1 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,4 +1,8 @@ -aiohttp==3.13.3 +aiohttp==3.13.3 +biplist==1.0.3 +async-timeout==5.0.1;python_version<'3.11' +cryptography==46.0.3 +chacha20poly1305-reuseable==0.13.2 async-timeout==5.0.1;python_version<'3.11' cryptography==46.0.3 chacha20poly1305-reuseable==0.13.2