├── LICENSE ├── README.md ├── custom_components └── apple_airplayer │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── device_manager.py │ ├── manifest.json │ ├── media_player.py │ └── translations │ ├── en.json │ ├── zh-Hans.json │ └── zh-Hant.json └── hacs.json /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 George Zhao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apple AirPlayer 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) 4 | [![Stable](https://img.shields.io/github/v/release/georgezhao2010/apple_airplayer)](https://github.com/georgezhao2010/apple_airplayer/releases/latest) 5 | 6 | Home Assistant integration component, make your AirPlay devices as TTS speakers. 7 | 8 | # Before Use 9 | ## 2021.6.X or earlier 10 | Apple Airplayer component requires [pyatv](https://pyatv.dev/development/) 0.8.1, which is self-contained in the latest version Home Assistant (2021.7.3). You can run `pip list | grep pyqatv` in your Home Assistant container host to check the version of pyatv. If lower than 0.8.1, you should run commands as below to upgrade pyatv. 11 | ``` 12 | apk update 13 | apk add build-base 14 | pip3 install --upgrade pyatv 15 | pip3 install --upgrade attrs 16 | ``` 17 | 18 | ## 2021.7.X or later 19 | There is a bug in Home Assistant 2021.7.3 ~ 2021.8.2 and it will cause HA core crash. 20 | If you are using HA 2021.7.3 ~ 2021.8.2, you need to reinstall miniaudio. 21 | Enter the following commands 22 | ``` 23 | apk update 24 | apk add build-base 25 | pip3 uninstall --yes miniaudio 26 | pip3 install miniaudio 27 | ``` 28 | Notice: If you upgrade HA core to new release, you need to re-install miniaudio. 29 | 30 | # Installtion 31 | Use HACS and Install as a custom repository, or copy all files in custom_components/apple_airplayer from [Latest Release](https://github.com/georgezhao2010/apple_airplayer/releases/latest) to your /custom_components/apple_airplayer in Home Assistant manually. Restart HomeAssistant. 32 | 33 | # Configuration 34 | Add Apple AirPlayer component in Home Assistant integrations page, it could auto-discover Airplay devices in network. If component can not found any devices, you can specify the IP address to add the device. 35 | 36 | # Supported Devices 37 | - AirPort Express 38 | - AirPort Express Gen2 39 | - Apple TV 2 40 | - Apple TV 3 41 | - Apple TV 4 42 | - Apple TV 4K 43 | - Apple TV 4K Gen2 44 | - HomePod 45 | - HomePod Mini 46 | - Other AirPlay compatible devices 47 | -------------------------------------------------------------------------------- /custom_components/apple_airplayer/__init__.py: -------------------------------------------------------------------------------- 1 | from .const import DOMAIN, DEVICES 2 | from homeassistant.core import HomeAssistant 3 | from homeassistant.const import CONF_DEVICE, CONF_ADDRESS 4 | 5 | async def async_setup(hass: HomeAssistant, hass_config: dict): 6 | hass.data.setdefault(DOMAIN, {}) 7 | return True 8 | 9 | 10 | async def async_setup_entry(hass: HomeAssistant, config_entry): 11 | identifier = config_entry.data.get(CONF_DEVICE) 12 | address = config_entry.data.get(CONF_ADDRESS) 13 | if DOMAIN not in hass.data: 14 | hass.data[DOMAIN] = {} 15 | if DEVICES not in hass.data[DOMAIN]: 16 | hass.data[DOMAIN][DEVICES] = [] 17 | if identifier is not None: 18 | hass.data[DOMAIN][DEVICES].append(identifier) 19 | elif address is not None: 20 | hass.data[DOMAIN][DEVICES].append(address) 21 | hass.async_create_task(hass.config_entries.async_forward_entry_setup( 22 | config_entry, "media_player")) 23 | return True 24 | 25 | 26 | async def async_unload_entry(hass: HomeAssistant, config_entry): 27 | identifier = config_entry.data.get(CONF_DEVICE) 28 | address = config_entry.data.get(CONF_ADDRESS) 29 | if identifier is not None: 30 | hass.data[DOMAIN][DEVICES].remove(identifier) 31 | elif address is not None: 32 | hass.data[DOMAIN][DEVICES].remove(address) 33 | await hass.config_entries.async_forward_entry_unload(config_entry, "media_player") 34 | return True 35 | -------------------------------------------------------------------------------- /custom_components/apple_airplayer/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from .const import DOMAIN, DEVICES, CONF_CACHE_DIR 3 | from homeassistant import config_entries 4 | from homeassistant.const import CONF_DEVICE, CONF_ADDRESS 5 | from .device_manager import DeviceManager 6 | import voluptuous as vol 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 12 | available_device = {} 13 | 14 | async def async_step_user(self, user_input=None, error=None): 15 | if DOMAIN not in self.hass.data: 16 | self.hass.data[DOMAIN] = {} 17 | if DEVICES not in self.hass.data[DOMAIN]: 18 | self.hass.data[DOMAIN][DEVICES] = [] 19 | if user_input is not None: 20 | man = DeviceManager(self.hass.loop) 21 | devices = await man.async_get_all_devices() 22 | self.available_device = {} 23 | for device in devices: 24 | if device.identifier not in self.hass.data[DOMAIN][DEVICES]: 25 | self.available_device[device.identifier] = f"{device.name}" 26 | if len(self.available_device) > 0: 27 | return await self.async_step_devinfo() 28 | else: 29 | return await self.async_step_manually() 30 | _LOGGER.debug(user_input, error) 31 | return self.async_show_form( 32 | step_id="user", 33 | errors={"base": error} if error else None 34 | ) 35 | 36 | async def async_step_devinfo(self, user_input=None, error=None): 37 | if user_input is not None: 38 | return self.async_create_entry( 39 | title=f"{self.available_device[user_input[CONF_DEVICE]]}", 40 | data=user_input) 41 | return self.async_show_form( 42 | step_id="devinfo", 43 | data_schema=vol.Schema({ 44 | vol.Required(CONF_DEVICE, default=sorted(self.available_device.keys())[0]): 45 | vol.In(self.available_device), 46 | vol.Required(CONF_CACHE_DIR, default="/tmp/tts"): str 47 | }), 48 | errors={"base": error} if error else None 49 | ) 50 | 51 | async def async_step_manually(self, user_input=None, error=None): 52 | if user_input is not None: 53 | man = DeviceManager(self.hass.loop) 54 | device = await man.async_get_device_by_address(user_input[CONF_ADDRESS]) 55 | if device is not None: 56 | identifier = device.identifier 57 | if identifier in self.hass.data[DOMAIN][DEVICES] or \ 58 | user_input[CONF_ADDRESS] in self.hass.data[DOMAIN][DEVICES]: 59 | return await self.async_step_manually(error="device_exist") 60 | return self.async_create_entry( 61 | title=f"{device.name}", 62 | data=user_input) 63 | else: 64 | return await self.async_step_manually(error="no_devices") 65 | 66 | return self.async_show_form( 67 | step_id="manually", 68 | data_schema=vol.Schema({ 69 | vol.Required(CONF_ADDRESS): str, 70 | vol.Required(CONF_CACHE_DIR, default="/tmp/tts"): str 71 | }), 72 | errors={"base": error} if error else None 73 | ) -------------------------------------------------------------------------------- /custom_components/apple_airplayer/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "apple_airplayer" 2 | DEVICES = "devices" 3 | CONF_CACHE_DIR = "cache_dir" 4 | -------------------------------------------------------------------------------- /custom_components/apple_airplayer/device_manager.py: -------------------------------------------------------------------------------- 1 | import pyatv 2 | import logging 3 | import asyncio 4 | from homeassistant.const import STATE_ON, STATE_OFF 5 | from pyatv.const import DeviceModel, FeatureName, FeatureState 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | _MODEL_LIST = { 10 | DeviceModel.AirPortExpress: "AirPort Express", 11 | DeviceModel.AirPortExpressGen2: "AirPort Express Gen2", 12 | DeviceModel.Gen2: "Apple TV 2", 13 | DeviceModel.Gen3: "Apple TV 3", 14 | DeviceModel.Gen4: "Apple TV 4", 15 | DeviceModel.Gen4K: "Apple TV 4K", 16 | DeviceModel.AppleTV4KGen2: "Apple TV 4K Gen2", 17 | DeviceModel.HomePod: "HomePod", 18 | DeviceModel.HomePodMini: "HomePod Mini", 19 | DeviceModel.Unknown: "Unknown" 20 | } 21 | 22 | 23 | class AirPlayDevice: 24 | def __init__(self, atv: pyatv.conf.AppleTV, event_loop): 25 | self._event_loop = event_loop 26 | self._atv_conf = atv 27 | self._atv_interface = None 28 | self._support_play_url = False 29 | self._support_stream_file = False 30 | self._support_volume = False 31 | self._support_volume_set = False 32 | 33 | @property 34 | def name(self) -> str: 35 | return self._atv_conf.name 36 | 37 | @property 38 | def identifier(self) -> str: 39 | return self._atv_conf.identifier.replace(":", "").lower() 40 | 41 | @property 42 | def address(self) -> str: 43 | return str(self._atv_conf.address) 44 | 45 | @property 46 | def model(self) -> str: 47 | return _MODEL_LIST.get(self._atv_conf.device_info.model, "Unknown") 48 | 49 | @property 50 | def version(self) -> str: 51 | return self._atv_conf.device_info.version if self._atv_conf.device_info.version is not None else "Unknown" 52 | 53 | @property 54 | def manufacturer(self) -> str: 55 | return "Apple Inc" if self.model != "Unknown" else "Unknown" 56 | 57 | @property 58 | def state(self) -> str: 59 | return STATE_OFF if self._atv_interface is None else STATE_ON 60 | 61 | @property 62 | def support_play_url(self) -> bool: 63 | return self._support_play_url 64 | 65 | @property 66 | def support_stream_file(self) -> bool: 67 | return self._support_stream_file 68 | 69 | @property 70 | def support_volume(self) -> bool: 71 | return self._support_volume 72 | 73 | @property 74 | def support_volume_set(self) -> bool: 75 | return self._support_volume_set 76 | 77 | @property 78 | def volume_level(self): 79 | if self._atv_interface is not None and self._support_volume: 80 | return self._atv_interface.audio.volume / 100 81 | else: 82 | return 0.5 83 | 84 | async def async_open(self): 85 | if self._atv_interface is None: 86 | try: 87 | self._atv_interface = await pyatv.connect(config=self._atv_conf, loop=self._event_loop) 88 | if self._atv_interface is not None: 89 | for k, f in self._atv_interface.features.all_features().items(): 90 | if k == FeatureName.PlayUrl and f.state == FeatureState.Available: 91 | self._support_play_url = True 92 | elif k == FeatureName.StreamFile and f.state == FeatureState.Available: 93 | self._support_stream_file = True 94 | elif k == FeatureName.Volume and f.state == FeatureState.Available: 95 | self._support_volume = True 96 | elif k == FeatureName.SetVolume and f.state == FeatureState.Available: 97 | self._support_volume_set = True 98 | 99 | 100 | except Exception as e: 101 | _LOGGER.error(f"Exception raised in async_open, {e}") 102 | 103 | async def async_close(self): 104 | if self._atv_interface is not None: 105 | try: 106 | self._atv_interface.close() 107 | self._atv_interface = None 108 | except Exception as e: 109 | _LOGGER.error(f"Exception raised in async_close, {e}") 110 | 111 | async def async_play_url(self, url): 112 | if self._atv_interface is None: 113 | await self.async_open() 114 | try: 115 | await self._atv_interface.stream.play_url(url) 116 | await asyncio.sleep(1) 117 | except Exception as e: 118 | _LOGGER.error(f"Exception raised in async_play_url, {e}") 119 | 120 | async def async_stream_file(self, filename): 121 | if self._atv_interface is None: 122 | await self.async_open() 123 | try: 124 | await self._atv_interface.stream.stream_file(filename) 125 | await asyncio.sleep(1) 126 | except Exception as e: 127 | _LOGGER.error(f"Exception raised in async_stream_file, {e}") 128 | 129 | async def async_set_volume(self, volume): 130 | if self._atv_interface is None: 131 | await self.async_open() 132 | try: 133 | if self._support_volume_set: 134 | await self._atv_interface.audio.set_volume(volume * 100) 135 | else: 136 | _LOGGER.error(f"Device is not supports set volume") 137 | except Exception as e: 138 | _LOGGER.error(f"Exception raised in async_set_volume, {e}") 139 | 140 | 141 | class DeviceManager: 142 | def __init__(self, event_loop): 143 | self._event_loop = event_loop 144 | 145 | async def async_get_all_devices(self): 146 | devices = [] 147 | atvs = await pyatv.scan(self._event_loop) 148 | for atv in atvs: 149 | device = AirPlayDevice(atv, self._event_loop) 150 | await device.async_open() 151 | if device.support_play_url or device.support_stream_file: 152 | devices.append(device) 153 | await device.async_close() 154 | return devices 155 | 156 | async def async_get_device_by_identifier(self, identifier): 157 | atvs = await pyatv.scan(self._event_loop) 158 | for atv in atvs: 159 | if atv.identifier.replace(":", "").lower() == identifier.lower(): 160 | return AirPlayDevice(atv, self._event_loop) 161 | return None 162 | 163 | async def async_get_device_by_address(self, address): 164 | atvs = await pyatv.scan(self._event_loop, hosts=[address]) 165 | if len(atvs) > 0: 166 | atv = atvs[0] 167 | device = AirPlayDevice(atv, self._event_loop) 168 | await device.async_open() 169 | await device.async_close() 170 | if device.support_play_url or device.support_stream_file: 171 | return device 172 | return None 173 | -------------------------------------------------------------------------------- /custom_components/apple_airplayer/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "apple_airplayer", 3 | "name": "Apple AirPlayer", 4 | "version": "v0.0.8", 5 | "config_flow": true, 6 | "documentation": "https://github.com/georgezhao2010/apple_airplayer", 7 | "issue_tracker": "https://github.com/georgezhao2010/apple_airplayer/issue", 8 | "requirements": ["pyatv>=0.8.2"], 9 | "dependencies": [], 10 | "iot_class": "local_push", 11 | "codeowners": ["@georgezhao2010"] 12 | } -------------------------------------------------------------------------------- /custom_components/apple_airplayer/media_player.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 4 | from homeassistant.components.media_player import ( 5 | SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_SET, 6 | SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_STEP, MediaPlayerEntity 7 | ) 8 | from .device_manager import DeviceManager, AirPlayDevice 9 | from .const import DOMAIN, CONF_CACHE_DIR 10 | from homeassistant.const import CONF_DEVICE, CONF_ADDRESS 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | async def async_setup_entry(hass, config_entry, async_add_entities): 16 | identifier = config_entry.data.get(CONF_DEVICE) 17 | address = config_entry.data.get(CONF_ADDRESS) 18 | cache_dir = config_entry.data[CONF_CACHE_DIR] 19 | man = DeviceManager(hass.loop) 20 | device = None 21 | if identifier is not None: 22 | device = await man.async_get_device_by_identifier(identifier) 23 | elif address is not None: 24 | device = await man.async_get_device_by_address(address) 25 | if device is not None: 26 | await device.async_open() 27 | async_add_entities([AirPlayer(device, cache_dir)]) 28 | 29 | 30 | class AirPlayer(MediaPlayerEntity): 31 | def __init__(self, player_device: AirPlayDevice, cache_dir): 32 | self._player_device = player_device 33 | self._unique_id = f"{DOMAIN}.airplay_{self._player_device.identifier}" 34 | self.entity_id = self._unique_id 35 | self._cache_dir = cache_dir 36 | self._features = SUPPORT_TURN_ON | SUPPORT_TURN_OFF 37 | if self._player_device.support_stream_file or self._player_device.support_play_url: 38 | self._features |= SUPPORT_PLAY_MEDIA 39 | if self._player_device.support_volume_set: 40 | self._features |= SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP 41 | self._device_info = { 42 | "identifiers": {(DOMAIN, self._player_device.identifier)}, 43 | "manufacturer": self._player_device.manufacturer, 44 | "model": self._player_device.model, 45 | "sw_version": self._player_device.version, 46 | "name": self._player_device.name 47 | } 48 | 49 | @property 50 | def unique_id(self): 51 | return self._unique_id 52 | 53 | @property 54 | def name(self): 55 | return self._player_device.name 56 | 57 | @property 58 | def volume_level(self) -> float: 59 | return self._player_device.volume_level 60 | 61 | @property 62 | def state(self): 63 | return self._player_device.state 64 | 65 | @property 66 | def supported_features(self): 67 | return self._features 68 | 69 | @property 70 | def device_info(self): 71 | return self._device_info 72 | 73 | async def async_set_volume_level(self, volume): 74 | if 0 <= volume <= 1: 75 | await self._player_device.async_set_volume(volume) 76 | 77 | async def async_turn_off(self): 78 | await self._player_device.async_close() 79 | 80 | async def async_turn_on(self): 81 | await self.hass.async_create_task(self._player_device.async_open()) 82 | 83 | async def async_play_media(self, media_type, media_id, **kwargs): 84 | if self._player_device.support_play_url: 85 | self.hass.async_create_task(self.async_play_url(media_id)) 86 | elif self._player_device.support_stream_file: 87 | self.hass.async_create_task(self.async_play_stream(media_id)) 88 | 89 | async def async_save_audio_file(self, filename, data): 90 | def save_audio(): 91 | with open(filename, "wb") as fp: 92 | fp.write(data) 93 | await self.hass.async_add_executor_job(save_audio) 94 | 95 | async def async_play_url(self, url): 96 | try: 97 | await self._player_device.async_play_url(url) 98 | except Exception as e: 99 | _LOGGER.debug(f"Play URL failed {e}") 100 | 101 | async def async_play_stream(self, url) : 102 | audio_file = url[url.rfind('/') + 1:] 103 | play = False 104 | filename = f"{self._cache_dir}/{audio_file}" 105 | if not os.path.exists(self._cache_dir): 106 | os.makedirs(self._cache_dir) 107 | if not os.path.exists(filename): 108 | _LOGGER.debug(f"File {audio_file} not in cache folder {self._cache_dir}, now downloading") 109 | session = async_create_clientsession(self.hass) 110 | r = await session.get(url) 111 | if r.status == 200: 112 | audio_data = await r.read() 113 | _LOGGER.debug(f"{len(audio_data)} bytes downloaded") 114 | await self.async_save_audio_file(filename, audio_data) 115 | play = True 116 | else: 117 | play = True 118 | if play: 119 | _LOGGER.debug(f"File {audio_file} in cache, now playing") 120 | try: 121 | await self._player_device.async_stream_file(filename) 122 | except Exception as e: 123 | _LOGGER.debug(f"Play stream failed {e}") 124 | -------------------------------------------------------------------------------- /custom_components/apple_airplayer/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_devices": "No available devices found on the network", 5 | "device_exist": "Device already configured, choice another one" 6 | }, 7 | "step": { 8 | "user": { 9 | "description": "Do you want to lookup new AirPlay devices?", 10 | "title": "Add new AirPlay device" 11 | }, 12 | "devinfo": { 13 | "data": { 14 | "device": "Device", 15 | "cache_dir": "Cache dir" 16 | }, 17 | "description": "Choice a device to add", 18 | "title": "New device found" 19 | }, 20 | "manually": { 21 | "data": { 22 | "Address": "address", 23 | "cache_dir": "Cache dir" 24 | }, 25 | "description": "Enter the IP address of the AirPlay device you want to add", 26 | "title": "No device found" 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /custom_components/apple_airplayer/translations/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_devices": "未在网络上发现可用的AirPlay设备", 5 | "device_exist": "设备已经存在,请添加其它设备" 6 | }, 7 | "step": { 8 | "user": { 9 | "description": "要查找添加新的AirPlay设备吗?", 10 | "title": "增加新的AirPlay设备" 11 | }, 12 | "devinfo": { 13 | "data": { 14 | "device": "设备", 15 | "cache_dir": "缓存目录" 16 | }, 17 | "description": "选择一个设备并添加", 18 | "title": "发现新设备" 19 | }, 20 | "manually": { 21 | "data": { 22 | "Address": "地址", 23 | "cache_dir": "缓存目录" 24 | }, 25 | "description": "输入你想要添加的设备的IP地址", 26 | "title": "没有自动发现设备" 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /custom_components/apple_airplayer/translations/zh-Hant.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_devices": "在網絡上找不到可用的 AirPlay 設備", 5 | "device_exist": "設備已經存在,請新增其他設備" 6 | }, 7 | "step": { 8 | "user": { 9 | "description": "要尋找新的 AirPlay 設備嗎?", 10 | "title": "新增 AirPlay 設備" 11 | }, 12 | "devinfo": { 13 | "data": { 14 | "device": "設備", 15 | "cache_dir": "暫存目錄" 16 | }, 17 | "description": "選擇一個設備並新增", 18 | "title": "發現新設備" 19 | }, 20 | "manually": { 21 | "data": { 22 | "Address": "地址", 23 | "cache_dir": "暫存目錄" 24 | }, 25 | "description": "輸入你想要新增的設備 IP 地址", 26 | "title": "沒有發現設備" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Apple AirPlayer", 3 | "domains": ["media_player"], 4 | "render_readme": true 5 | } 6 | --------------------------------------------------------------------------------