async def _send_command(self, identifier: str, content: Dict[str, object]) -> Dict[str, object]: """Send a command to the device and return response.""" await self._connect() if self._protocol is None: raise RuntimeError("not connected to companion") try: resp = await self._protocol.exchange_opack( FrameType.E_OPACK, { "_i": identifier, "_x": 12356, # Dummy XID, not sure what to use "_t": "2", # Request "_c": content, }, ) except Exception as ex: raise exceptions.ProtocolError( f"Command {identifier} failed") from ex else: # Check if an error was present and throw exception if that's the case if "_em" in resp: raise exceptions.ProtocolError( f"Command {identifier} failed: {resp['_em']}") return resp
async def finish_pairing(self, username: str, pin_code: int) -> HapCredentials: """Finish pairing process. A username (generated by new_credentials) and the PIN code shown on screen must be provided. """ # Step 1 client_id = (binascii.hexlify( self.srp.credentials.client_id).decode("ascii").upper()) self.srp.step1(client_id, pin_code) resp = await self._send_plist(method="pin", user=client_id) resp = plistlib.loads(resp.body if isinstance(resp.body, bytes) else resp.body.encode("utf-8")) if not isinstance(resp, dict): raise exceptions.ProtocolError(f"exoected dict, got {type(resp)}") # Step 2 pub_key, key_proof = self.srp.step2(resp["pk"], resp["salt"]) await self._send_plist(pk=binascii.unhexlify(pub_key), proof=binascii.unhexlify(key_proof)) # Step 3 epk, tag = self.srp.step3() await self._send_plist(epk=epk, authTag=tag) return self.srp.credentials
async def _send_command( self, identifier: str, content: Dict[str, object], message_type: MessageType = MessageType.Request, ) -> Mapping[str, Any]: """Send a command to the device and return response.""" await self.connect() if self._protocol is None: raise RuntimeError("not connected to companion") try: resp = await self._protocol.exchange_opack( FrameType.E_OPACK, { "_i": identifier, "_t": message_type.value, "_c": content, }, ) except exceptions.ProtocolError: raise except Exception as ex: raise exceptions.ProtocolError( f"Command {identifier} failed") from ex else: return resp
async def app_list(self) -> List[App]: """Fetch a list of apps that can be launched.""" app_list = await self.api.app_list() if "_c" not in app_list: raise exceptions.ProtocolError("missing content in response") content = cast(dict, app_list["_c"]) return [App(name, bundle_id) for bundle_id, name in content.items()]
async def set_volume(self, level: float) -> None: """Change current volume level.""" if self._output_device_uid is None: raise exceptions.ProtocolError("no output device") await self.protocol.send( messages.set_volume(self._output_device_uid, level / 100.0)) if self._volume != level: await asyncio.wait_for(self._volume_event.wait(), timeout=5.0)
def get_audio_properties( properties: Mapping[str, str]) -> Tuple[int, int, int]: """Parse Zeroconf properties and return sample rate, channels and sample size.""" try: sample_rate = int(properties.get("sr", DEFAULT_SAMPLE_RATE)) channels = int(properties.get("ch", DEAFULT_CHANNELS)) sample_size = int(int(properties.get("ss", DEFAULT_SAMPLE_SIZE)) / 8) except Exception as ex: raise exceptions.ProtocolError("invalid audio property") from ex else: return sample_rate, channels, sample_size
async def _exchange_generic_opack( self, frame_type: FrameType, data: Dict[str, Any], identifier: FrameIdType, timeout: float, ) -> Dict[str, object]: _LOGGER.debug("Exchange OPACK: %s", data) self.send_opack(frame_type, data) self._queues[identifier] = SharedData() unpacked_object = await self._queues[identifier].wait(timeout) if not isinstance(unpacked_object, dict): raise exceptions.ProtocolError( f"Received unexpected type: {type(unpacked_object)}") if "_em" in unpacked_object: raise exceptions.ProtocolError( f"Command failed: {unpacked_object['_em']}") return unpacked_object
def _get_pairing_data(message: Dict[str, object]): pairing_data = message.get(PAIRING_DATA_KEY) if not pairing_data: raise exceptions.AuthenticationError("no pairing data in message") if not isinstance(pairing_data, bytes): raise exceptions.ProtocolError( f"Pairing data has unexpected type: {type(pairing_data)}") tlv = read_tlv(pairing_data) if TlvValue.Error in tlv: raise exceptions.AuthenticationError(stringify(tlv)) return tlv
async def _session_start(self) -> None: local_sid = randint(0, 2**32 - 1) resp = await self._send_command("_sessionStart", { "_srvT": "com.apple.tvremoteservices", "_sid": local_sid }) content = resp.get("_c") if content is None: raise exceptions.ProtocolError("missing content") remote_sid = cast(Mapping[str, Any], resp["_c"])["_sid"] self.sid = (remote_sid << 32) | local_sid _LOGGER.debug("Started session with SID 0x%X", self.sid)
async def start(self): """Connect to device and listen to incoming messages.""" if self._is_started: raise exceptions.ProtocolError("Already started") self._is_started = True await self.connection.connect() if self.service.credentials: self.srp.pairing_id = HapCredentials.parse( self.service.credentials).client_id _LOGGER.debug("Companion credentials: %s", self.service.credentials) await self._setup_encryption()
async def send_and_receive( self, method: str, uri: str, protocol: str = "HTTP/1.1", user_agent: str = USER_AGENT, content_type: Optional[str] = None, headers: Optional[Mapping[str, object]] = None, body: Optional[Union[str, bytes]] = None, allow_error: bool = False, ) -> HttpResponse: """Send a HTTP message and return response.""" output = _format_message(method, uri, protocol, user_agent, content_type, headers, body) _LOGGER.debug("Sending %s message: %s", protocol, output) if not self.transport: raise RuntimeError("not connected to remote") self.transport.write(output) event = asyncio.Event() self._requests.appendleft(event) try: await asyncio.wait_for(event.wait(), timeout=4) response = cast(HttpResponse, self._responses.get()) except asyncio.TimeoutError as ex: raise TimeoutError( f"no response to {method} {uri} ({protocol})") from ex finally: # If request failed and event is still in request queue, remove it if self._requests and self._requests[-1] == event: self._requests.pop() _LOGGER.debug("Got %s response: %s:", response.protocol, response) if response.code in [401, 403]: raise exceptions.AuthenticationError("not authenticated") # Positive response if 200 <= response.code < 300 or allow_error: return response raise exceptions.ProtocolError( f"{protocol} method {method} failed with code " f"{response.code}: {response.message}")
async def exchange_opack( self, frame_type: FrameType, data: object, timeout: int = DEFAULT_TIMEOUT) -> Dict[str, object]: """Send data as OPACK and decode result as OPACK.""" _LOGGER.debug("Send OPACK: %s", data) _, payload = await self.connection.exchange(frame_type, opack.pack(data), timeout) unpacked_object, _ = opack.unpack(payload) _LOGGER.debug("Receive OPACK: %s", unpacked_object) if not isinstance(unpacked_object, dict): raise exceptions.ProtocolError( f"Received unexpected type: {type(unpacked_object)}") return unpacked_object
async def _send_event(self, identifier: str, content: Mapping[str, Any]) -> None: """Subscribe to updates to an event.""" await self.connect() if self._protocol is None: raise RuntimeError("not connected to companion") try: self._protocol.send_opack( FrameType.E_OPACK, { "_i": identifier, "_t": MessageType.Event.value, "_c": content, }, ) except exceptions.ProtocolError: raise except Exception as ex: raise exceptions.ProtocolError("Send event failed") from ex
async def stream_file(self, file: Union[str, io.BufferedReader], **kwargs) -> None: """Stream local or remote file to device. Supports either local file paths or a HTTP(s) address. INCUBATING METHOD - MIGHT CHANGE IN THE FUTURE! """ self.playback_manager.acquire() audio_file: Optional[AudioSource] = None takeover_release = self.core.takeover(Audio, Metadata, PushUpdater, RemoteControl) try: client, _, context = await self.playback_manager.setup() client.credentials = parse_credentials( self.core.service.credentials) client.password = self.core.service.password client.listener = self.listener await client.initialize(self.core.service.properties) # Try to load metadata and pass it along if it succeeds metadata: AudioMetadata = EMPTY_METADATA try: # Source must support seeking to read metadata (or point to file) if (isinstance(file, io.BufferedReader) and file.seekable()) or (isinstance(file, str) and path.exists(file)): metadata = await get_metadata(file) else: _LOGGER.debug( "Seeking not supported by source, not loading metadata" ) except Exception as ex: _LOGGER.exception("Failed to extract metadata from %s: %s", file, ex) # After initialize has been called, all the audio properties will be # initialized and can be used in the miniaudio wrapper audio_file = await open_source( file, context.sample_rate, context.channels, context.bytes_per_channel, ) # If the user didn't change volume level prior to streaming, try to extract # volume level from device (if supported). Otherwise set the default level # in pyatv. if not self.audio.has_changed_volume and "initialVolume" in client.info: initial_volume = client.info["initialVolume"] if not isinstance(initial_volume, float): raise exceptions.ProtocolError( f"initial volume {initial_volume} has " "incorrect type {type(initial_volume)}", ) context.volume = initial_volume else: await self.audio.set_volume(self.audio.volume) await client.send_audio(audio_file, metadata) finally: takeover_release() if audio_file: await audio_file.close() await self.playback_manager.teardown()
async def send_audio(self, wave_file, metadata: AudioMetadata = EMPTY_METADATA): """Send an audio stream to the device.""" if self.control_client is None or self.timing_client is None: raise Exception("not initialized") # TODO: better exception transport = None try: # Set up the streaming session await self._setup_session() # Create a socket used for writing audio packets (ugly) transport, _ = await self.loop.create_datagram_endpoint( AudioProtocol, remote_addr=(self.rtsp.connection.remote_ip, self.context.server_port), ) # Start sending sync packets self.control_client.start(self.rtsp.connection.remote_ip) # Send progress if supported by receiver if MetadataType.Progress in self._metadata_types: start = self.context.start_ts now = self.context.rtptime end = (self.context.start_ts + wave_file.getduration() * self.context.sample_rate) await self.rtsp.set_parameter("progress", f"{start}/{now}/{end}") # Apply text metadata if it is supported self._metadata = metadata if MetadataType.Text in self._metadata_types: _LOGGER.debug("Playing with metadata: %s", self.metadata) await self.rtsp.set_metadata(self.context.rtpseq, self.context.rtptime, self.metadata) # Set a decent volume (range is [-30.0, 0] and -144 is muted) await self.rtsp.set_parameter("volume", "-20") # Start keep-alive task to ensure connection is not closed by remote device self._keep_alive_task = asyncio.ensure_future( self._send_keep_alive()) listener = self.listener if listener: listener.playing(self.metadata) # Start playback await self.rtsp.record(self.context.rtpseq, self.context.rtptime) await self._stream_data(wave_file, transport) except ( # pylint: disable=try-except-raise exceptions.ProtocolError, exceptions.AuthenticationError, ): raise # Re-raise internal exceptions to maintain a proper stack trace except Exception as ex: raise exceptions.ProtocolError( "an error occurred during streaming") from ex finally: self._packet_backlog.clear( ) # Don't keep old packets around (big!) if transport: transport.close() if self._keep_alive_task: self._keep_alive_task.cancel() self._keep_alive_task = None self.control_client.stop() listener = self.listener if listener: listener.stopped()
def volume(self) -> float: """Return current volume level.""" volume = self.relay("volume") if 0.0 <= volume <= 100.0: return volume raise exceptions.ProtocolError(f"volume {volume} is out of range")
async def set_volume(self, level: float) -> None: """Change current volume level.""" if 0.0 <= level <= 100.0: await self.relay("set_volume")(level) else: raise exceptions.ProtocolError(f"volume {level} is out of range")