"""Manage announced AirPlay features.""" from enum import Enum, IntFlag, auto import logging import math import plistlib import re from typing import Any, Dict, Mapping, Union from pyatv.auth.hap_pairing import ( TRANSIENT_CREDENTIALS, AuthenticationType, HapCredentials, ) from pyatv.const import PairingRequirement from pyatv.core import MutableService from pyatv.interface import BaseService from pyatv.settings import AirPlayVersion from pyatv.support import map_range from pyatv.support.http import HttpRequest, HttpResponse # pylint: disable=invalid-name # Status flags PIN_REQUIRED = 0x8 PASSWORD_BIT = 0x80 LEGACY_PAIRING_BIT = 0x200 DBFS_MIN = -30.0 DBFS_MAX = 0.0 PERCENTAGE_MIN = 0.0 PERCENTAGE_MAX = 100.0 UNSUPPORTED_MODELS = [r"^Mac\d+,\d+$"] class AirPlayMajorVersion(Enum): """Major AirPlay protocol version.""" AirPlayV1 = auto() AirPlayV2 = auto() def _get_flags(properties: Mapping[str, str]) -> int: # Flags are either present via "sf" or "flags" flags = properties.get("sf") or properties.get("flags") or "0x0" return int(flags, 16) # These flags have been imported from here: # https://emanuelecozzi.net/docs/airplay2/features/ # Should be the same as the ones from here: # https://openairplay.github.io/airplay-spec/features.html # But seems to be some inconsistencies. Worth to keep in mind. class AirPlayFlags(IntFlag): """Features supported by AirPlay.""" SupportsAirPlayVideoV1 = 1 << 0 SupportsAirPlayPhoto = 1 << 1 SupportsAirPlaySlideShow = 1 << 5 SupportsAirPlayScreen = 1 << 7 SupportsAirPlayAudio = 1 << 9 AudioRedundant = 1 << 11 Authentication_4 = 1 << 14 MetadataFeatures_0 = 1 << 15 MetadataFeatures_1 = 1 << 16 MetadataFeatures_2 = 1 << 17 AudioFormats_0 = 1 << 18 AudioFormats_1 = 1 << 19 AudioFormats_2 = 1 << 20 AudioFormats_3 = 1 << 21 Authentication_1 = 1 << 23 Authentication_8 = 1 << 26 SupportsLegacyPairing = 1 << 27 HasUnifiedAdvertiserInfo = 1 << 30 IsCarPlay = 1 << 32 # SupportsVolume? SupportsAirPlayVideoPlayQueue = 1 << 33 SupportsAirPlayFromCloud = 1 << 34 SupportsTLS_PSK = 1 << 35 SupportsUnifiedMediaControl = 1 << 38 SupportsBufferedAudio = 1 << 40 SupportsPTP = 1 << 41 SupportsScreenMultiCodec = 1 << 42 SupportsSystemPairing = 1 << 43 IsAPValeriaScreenSender = 1 << 44 SupportsHKPairingAndAccessControl = 1 << 46 SupportsCoreUtilsPairingAndEncryption = 1 << 48 SupportsAirPlayVideoV2 = 1 << 49 MetadataFeatures_3 = 1 << 50 SupportsUnifiedPairSetupandMFi = 1 << 51 SupportsSetPeersExtendedMessage = 1 << 52 SupportsAPSync = 1 << 54 SupportsWoL = 1 << 55 SupportsWoL2 = 1 << 56 SupportsHangdogRemoteControl = 1 << 58 SupportsAudioStreamConnectionSetup = 1 << 59 SupportsAudioMetadataControl = 1 << 60 SupportsRFC2198Redundancy = 1 << 61 # pylint: enable=invalid-name def parse_features(features: str) -> AirPlayFlags: """Parse an AirPlay feature string and return what is supported. A feature string have one of the following formats: - 0x12345678 - 0x12345678,0xabcdef12 => 0xabcdef1212345678 """ match = re.match(r"^0x([0-9A-Fa-f]{1,8})(?:,0x([0-9A-Fa-f]{1,8})|)$", features) if match is None: raise ValueError(f"invalid feature string: {features}") value, upper = match.groups() if upper is not None: value = upper + value return AirPlayFlags(int(value, 16)) def is_password_required(service: BaseService) -> bool: """Return if password is required by AirPlay service. A password is required under these conditions: - "pw" is true - "sf", "ft" or "flags" has bit 0x80 set """ # "pw" flag if service.properties.get("pw", "false").lower() == "true": return True # Legacy "flags" property if _get_flags(service.properties) & PASSWORD_BIT: return True return False def get_pairing_requirement(service: BaseService) -> PairingRequirement: """Return pairing requirement for service. Pairing requirement is Mandatory if: - Bit 0x200 is set in sf (AirPlay v1)/flags (AirPlay v2) - But 0x8 set in sf/flags There's an "act" (Access Control Type) field present in some cases. Values are yet unknown, but "2" seems to correspond to "Current User". Other cases are optimistically treated as NotNeeded. """ if _get_flags(service.properties) & (LEGACY_PAIRING_BIT | PIN_REQUIRED): return PairingRequirement.Mandatory # "Current User" not supported by pyatv if service.properties.get("act", "0") == "2": return PairingRequirement.Unsupported return PairingRequirement.NotNeeded # TODO: It is not fully understood how to determine if a device supports remote control # over AirPlay, so this method makes a pure guess. We know that Apple TVs running tvOS # X (X>=13?) support it as well as HomePods, something we can identify from the model # string. This implementation should however be improved when it's properly known how # to check for support. def is_remote_control_supported( service: BaseService, credentials: HapCredentials ) -> bool: """Return if device supports remote control tunneling.""" model = service.properties.get("model", "") # HomePod supports remote control but only with transient credentials if model.startswith("AudioAccessory"): return credentials == TRANSIENT_CREDENTIALS if not model.startswith("AppleTV"): return False # tvOS must be at least version 13 and HAP credentials are required by Apple TV version = service.properties.get("osvers", "0.0").split(".", maxsplit=1)[0] return float(version) >= 13.0 and credentials.type == AuthenticationType.HAP def encode_plist_body(data: Any): """Encode a binary plist payload.""" return plistlib.dumps( data, fmt=plistlib.FMT_BINARY, # pylint: disable=no-member ) 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 plistlib.loads(body if isinstance(body, bytes) else body.encode("utf-8")) except plistlib.InvalidFileException: return None def log_request(logger, request: HttpRequest, message_prefix="") -> None: """Log an AirPlay request with optional binary plist body.""" if not logger.isEnabledFor(logging.DEBUG): return logger.debug("%sRequest: %s", message_prefix, request) if request.headers.get("content-type") == "application/x-apple-binary-plist" and ( payload := decode_plist_body(request.body) ): logger.debug( "%s%s request plist content: %s", message_prefix, request.protocol, payload, ) def log_response(logger, response: HttpResponse, message_prefix="") -> None: """Log an AirPlay response with optional binary plist body.""" if not logger.isEnabledFor(logging.DEBUG): return logger.debug("%sResponse: %s", message_prefix, response) if ( response and response.headers.get("content-type") == "application/x-apple-binary-plist" and (payload := decode_plist_body(response.body)) ): logger.debug( "%s%s response plist content: %s", message_prefix, response.protocol, payload, ) # TODO: I don't know how to properly detect if a receiver support AirPlay 2 or not, # so I'm guessing until I know better. The basic idea here is simple: the service # should have the "features" flag (either "features" or "ft") and either bit 38 or # bit 48 should be present. def get_protocol_version( service: BaseService, preferred_version: AirPlayVersion ) -> AirPlayMajorVersion: """Return major AirPlay version supported by a service.""" if preferred_version == AirPlayVersion.Auto: features = service.properties.get("ft") if not features: features = service.properties.get("features", "0x0") parsed_features = parse_features(features) if ( AirPlayFlags.SupportsUnifiedMediaControl in parsed_features or AirPlayFlags.SupportsCoreUtilsPairingAndEncryption in parsed_features ): return AirPlayMajorVersion.AirPlayV2 return AirPlayMajorVersion.AirPlayV1 if preferred_version == AirPlayVersion.V2: return AirPlayMajorVersion.AirPlayV2 return AirPlayMajorVersion.AirPlayV1 def update_service_details(service: MutableService): """Update AirPlay service according to that it supports.""" service.requires_password = is_password_required(service) if service.properties.get("acl", "0") == "1": # Access control might say that pairing is not possible, e.g. only devices # belonging to the same home (not supported by pyatv) service.pairing = PairingRequirement.Disabled elif any( re.match(model, service.properties.get("model", "")) for model in UNSUPPORTED_MODELS ): # Set as "unsupported" for devices we know that pyatv does # (yet) support. service.pairing = PairingRequirement.Unsupported else: service.pairing = get_pairing_requirement(service) def pct_to_dbfs(level: float) -> float: """Convert percentage level to dBFS. Used for volume levels in AirPlay. """ # AirPlay uses -144.0 as muted volume, so re-map 0.0 to that if math.isclose(level, 0.0): return -144.0 # Map percentage to dBFS return map_range(level, PERCENTAGE_MIN, PERCENTAGE_MAX, DBFS_MIN, DBFS_MAX) def dbfs_to_pct(level: float) -> float: """Convert dBFS to percentage.""" # AirPlay uses -144.0 as "muted", but we treat everything below -30.0 as # muted to be a bit defensive if level < DBFS_MIN: return PERCENTAGE_MIN # Map dBFS to percentage return map_range(level, DBFS_MIN, DBFS_MAX, PERCENTAGE_MIN, PERCENTAGE_MAX)