├── README.md ├── custom_components └── linkplay │ ├── __init__.py │ ├── manifest.json │ ├── media_player.py │ └── services.yaml ├── hacs.json └── info.md /README.md: -------------------------------------------------------------------------------- 1 | # Linkplay-based speakers and sound devices 2 | 3 | **NOTE: The `linkplay` custom component for Home Assistant is deprecated, and unsupported! Starting with Home Assistant 2024.08 release, Linkplay chipset based media players are officially supported without the need of any custom component.** 4 | 5 | To switch to the official LinkPlay integration starting from **2024.08**, follow these steps: 6 | * Remove the current `linkplay` configuration from your configuration.yaml. 7 | * Restart Home-Assistant. 8 | * Delete the custom component through HACS or manually by deleting the `custom_components/linkplay` folder. 9 | * Restart Home-Assistant again. Your players will be automatically discovered, a notification popup will inform you on this. 10 | 11 | For any bugs, feature requests, or PRs, please use the official Home Assistant channels. 12 | 13 | --- 14 | **For Home Assistant versions before 2024.08:** 15 | 16 | This component allows you to integrate control of audio devices based on Linkplay A31 chipset into your [Home Assistant](http://www.home-assistant.io) smart home system. Originally developed by nicjo814, maintained by limych. This version rewritten by nagyrobi. Read more about Linkplay at the bottom of this file. 17 | 18 | Fully compatible with [Mini Media Player card for Lovelace UI](https://github.com/kalkih/mini-media-player) by kalkih, including speaker group management. 19 | 20 | ## Installation 21 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) 22 | * Install using HACS, or manually: copy all files in `custom_components/linkplay` to your `/custom_components/linkplay/` directory. 23 | * Restart Home-Assistant. 24 | * Add the configuration to your configuration.yaml. 25 | * Restart Home-Assistant again. 26 | 27 | [Support forum](https://community.home-assistant.io/t/linkplay-integration/33878/133) 28 | 29 | ### Configuration 30 | 31 | It is recommended to create static DHCP leases in your network router to ensure the devices always get the same IP address. Recent models versions allow setting static IP address, if you see that option, use it. 32 | 33 | To add Linkplay units to your installation, add the following to your `configuration.yaml` file: 34 | 35 | ```yaml 36 | # Example configuration.yaml entry 37 | media_player: 38 | - platform: linkplay 39 | host: 192.168.1.11 40 | protocol: https 41 | name: Sound Room1 42 | volume_step: 10 43 | icecast_metadata: 'StationNameSongTitle' 44 | multiroom_wifidirect: False 45 | sources: 46 | { 47 | 'optical': 'TV sound', 48 | 'line-in': 'Radio tuner', 49 | 'bluetooth': 'Bluetooth', 50 | 'udisk': 'USB stick', 51 | 'http://94.199.183.186:8000/jazzy-soul.mp3': 'Jazzy Soul', 52 | } 53 | 54 | - platform: linkplay 55 | host: 192.168.1.12 56 | name: Sound Room2 57 | uuid: 'FF31F09E82A6BBC1A2CB6D80' 58 | icecast_metadata: 'Off' # valid values: 'Off', 'StationName', 'StationNameSongTitle' 59 | sources: {} 60 | common_sources: !include linkplay-radio-sources.yaml 61 | ``` 62 | 63 | ### Configuration Variables 64 | 65 | **host:** 66 | *(string)* *(Required)* The IP address of the Linkplay unit. Note that using a hostname will not work with a few commands, e.g., joining multiroom groups. 67 | 68 | **protocol:** 69 | *(string)* *(Optional)* The protocol used by the device. Can be one of `http` or `https`. If omitted, the player will first try on `https` and if fails will switch to `http`, but that may cause issues when the player becomes unreachable and comes back. 70 | 71 | **name:** 72 | *(string)* *(Required)* Name that Home Assistant will generate the `entity_id` based on. It is also the base of the friendly name seen in the dashboard, but will be overriden by the device name set in the Android app. 73 | 74 | **uuid:** 75 | *(string)* *(Optional)* Hardware UUID of the player. Can be read out from the attibutes of the entity. Set it manually to that value to handle double-added entity cases when Home Assistant starts up without the Linkplay device being on the network at that moment. 76 | 77 | **volume_step:** 78 | *(integer)* *(Optional)* Step size in percent to change volume when calling `volume_up` or `volume_down` service against the media player. Defaults to `5`, can be a number between `1` and `25`. 79 | 80 | **sources:** 81 | *(list)* *(Optional)* A list with available source inputs on the device. If not specified, the integration will assume that all the supported source input types are present on it: 82 | ```yaml 83 | 'bluetooth': 'Bluetooth', 84 | 'line-in': 'Line-in', 85 | 'line-in2': 'Line-in 2', 86 | 'optical': 'Optical', 87 | 'co-axial': 'Coaxial', 88 | 'HDMI': 'HDMI', 89 | 'udisk': 'USB disk', 90 | 'TFcard': 'SD card', 91 | 'RCA': 'RCA', 92 | 'XLR': 'XLR', 93 | 'FM': 'FM', 94 | 'cd': 'CD' 95 | ``` 96 | The sources can be renamed to your preference (change only the part _after_ **:** ). You can also specify http-based (Icecast / Shoutcast) internet radio streams as input sources: 97 | ```yaml 98 | 'http://1.2.3.4:8000/your_radio': 'Your Radio', 99 | 'http://icecast.streamserver.tld/mountpoint.aac': 'Another radio' 100 | ``` 101 | If you don't want a source selector to be available at all, set option to empty: `sources: {}`. 102 | 103 | _Note:_ **Don't** use HTTP**S** streams. Linkplay chipsets seem to have limited supporrt for HTTPS. Besides, using HTTPS is useless in practice for a public webradio stream, it's a waste of computig resources for this kind of usage both on server and player side. 104 | 105 | **common_sources:** 106 | *(list)* *(Optional)* Another list with sources which should appear on the device. Useful if you have multiple devices on the network and you'd like to maintain a common list of http-based internet radio stream sources for all of them in a single file with `!include linkplay-radio-sources.yaml`. The included file should be in the same place as the main config file containing `linkplay` platform. 107 | For example: 108 | ```yaml 109 | { 110 | 'http://1.2.3.4:8000/your_radio': 'Your Radio', 111 | 'http://icecast.streamserver.tld/mountpoint.aac': 'Another radio' 112 | } 113 | ``` 114 | 115 | **icecast_metadata:** 116 | *(string)* *(Optional)* When playing icecast webradio streams, how to handle metadata. Valid values here are `'Off'`, `'StationName'`, `'StationNameSongTitle'`, defaulting to `'StationName'` when not set. With `'Off'`, Home Assistant will not try do request any metadata from the IceCast server. With `'StationName'`, Home Assistant will request only once when starting the playback the stream name from the headers, and display it in the `media_title` property of the player. With `'StationNameSongTitle'` Home Assistant will request the stream server periodically for icy-metadata, and read out `StreamTitle`, trying to figure out correct values for `media_title` and `media_artist`, in order to gather cover art information from LastFM service (see below). Note that metadata retrieval success depends on how the icecast radio station servers and encoders are configured, if they don't provide proper infos or they don't display correctly, it's better to turn it off or just use StationName to save server load. There's no standard way enforced on the servers, it's up to the server maintainers how it works. 117 | 118 | **lastfm_api_key:** 119 | *(string)* *(Optional)* API key to LastFM service to get album covers. Register for one. 120 | 121 | **multiroom_wifidirect:** 122 | *(boolean)* *(Optional)* Set to `True` to override the default router mode used by the component with wifi-direct connection mode (more details below). 123 | 124 | **led_off:** 125 | *(boolean)* *(Optional)* Set to `True` to turn off the LED on the front panel of the Arylic devices (works only for this brand). 126 | 127 | 128 | ## Multiroom 129 | 130 | Linkplay devices support multiroom in two modes: 131 | - WiFi direct mode, where the master turns into a hidden AP and the slaves connect directly to it. The advantage is that this is a dedicated direct connection between the speakers, with network parameters optimized by the factory for streaming. Disadvantage is that switching of the stream is slower, plus the coverage can be limited due to the building's specifics. _This is the default method used by the Android app to create multirooms._ 132 | - Router mode, where the master and slaves connect to each other through the local network (from firmware `v4.2.8020` up). The advantage is that all speakers remain connected to the existing network, swicthing the stream happens faster, and the coverage can be bigger being ensured by the network infrastructure of the building (works through multiple interconnected APs and switches). Disadvantage is that the network is not dedicated and it's the user responsibility to provide proper network infrastructure for reliable streaming. _This only works through this component and it's the preferred mode._ 133 | 134 | This integration will autodetect the firmware version running on the player and choose multiroom mode accordingly. Units with firmware version lower than `v4.2.8020` can connect to multirooms _only in wifi-direct mode_. Firmware version number can be seen in device attributes. If the user has a mix of players running old and new firmware, autodetection can be overriden with option `multiroom_wifidirect: True`, and is needed only for units with newer versions, to force them down to wifi-direct multiroom. 135 | 136 | To create a multiroom group, connect `media_player.sound_room2` (slave) to `media_player.sound_room1` (master): 137 | ```yaml 138 | - service: linkplay.join 139 | data: 140 | entity_id: media_player.sound_room2 141 | master: media_player.sound_room1 142 | ``` 143 | To exit from the multiroom group, use the entity ids of the players that need to be unjoined. If this is the entity of a master, all slaves will be disconnected: 144 | ```yaml 145 | - service: linkplay.unjoin 146 | data: 147 | entity_id: media_player.sound_room1 148 | ``` 149 | These services are compatible out of the box with the speaker group object in @kalkih's [Mini Media Player](https://github.com/kalkih/mini-media-player) card for Lovelace UI. 150 | 151 | It's also possible to use Home Assistant's [standard multiroom](https://www.home-assistant.io/integrations/media_player/#service-media_playerjoin) join and unjoin functions for multiroom control. 152 | 153 | *Tip*: if you experience temporary `Unavailable` status on the slaves afer unjoining from a multiroom group in router mode, run once the Linkplay-specific command `RouterMultiroomEnable` - see details further down. 154 | 155 | ## Presets 156 | 157 | Linkplay devices allow to save, using the control app on the phone/tablet, music presets (for example Spotify playlists) to be recalled for later listening. Recalling a preset from Home Assistant: 158 | ```yaml 159 | - service: linkplay.preset 160 | data: 161 | entity_id: media_player.sound_room1 162 | preset: 1 163 | ``` 164 | Preset count vary from device type to type, usually the phone app shows how many presets can be stored maximum. The integration detects the max number and the command only accepts numbers from the allowed range. You can specify multiple entity ids separated by comma or use `all` to run the service against. 165 | 166 | ## Specific commands 167 | 168 | Linkplay devices support some commands through the API, this is a wrapper to be able to use these in Home Assistant: 169 | ```yaml 170 | - service: linkplay.command 171 | data: 172 | entity_id: media_player.sound_room1 173 | command: TimeSync 174 | notify: False 175 | ``` 176 | Implemented commands: 177 | - `PromptEnable` and `PromptDisable` - enable or disable the audio prompts played through the speakers when connecting to the network or joining multiroom etc. 178 | - `"WriteDeviceNameToUnit: My Device Name"` - change the friendly name of the device both in firmware and in Home Assistant. Needs to be in qoutes. 179 | - `"SetApSSIDName: NewWifiName"` - change the SSID name of the AP created by the unit for wifidirect multiroom connections. Needs to be in qoutes. 180 | - `SetRandomWifiKey`- perhaps as an extra security feature, one could make an automation to change the keys on the APs to some random values periodically. 181 | - `TimeSync` - is for units on networks not connected to internet to compensate for an unreachable NTP server. Correct time is needed for the alarm clock functionality (not implemented yet here). 182 | - `RouterMultiroomEnable` - router mode is available by default in firmwares above v4.2.8020, but there’s also a logic included to build it up, this command ensures to set the good priority. Only use if you have issues with multiroom in router mode. 183 | - `MCU+XXX+XXX` - passthrough for direct TCP UART commands [supported by the module](https://forum.arylic.com/t/home-assistant-integratio-available/729/23). Input not validated, use at your own risk. 184 | - `Rescan` - do not wait for the current 60 second throttle cycle to reconnect to the unavailable devices, trigger testing for availability immediately. 185 | 186 | If parameter `notify: False` is omitted, results will appear in Lovelace UI's left pane as persistent notifications which can be dismissed. You can specify multiple entity ids separated by comma or use `all` to run the service against. 187 | 188 | ## Snapshot and restore 189 | 190 | These functions are useless since Home Assistant 2022.6 because this component has support for announcements so it does the snapshot and the restore automatically for any TTS message coming in. 191 | See below on how to call a TTS announcement service. 192 | 193 | To prepare the player to play TTS and save the current state of it for restoring afterwards, current playback will stop: 194 | ```yaml 195 | - service: linkplay.snapshot 196 | data: 197 | entity_id: media_player.sound_room1 198 | switchinput: true 199 | ``` 200 | Note the `switchinput` parameter: if the currently playing source is Spotify and this parameter is `True`, it will only save the current volume of the player. You can use Home Assistant's Spotify integration to pause playback within an automation (read further below). If it's `False`, it will save the current Spotify playlist to the player's preset memory. With other playback sources (like Line-In), it will only switch to network playback. 201 | 202 | To restore the player state: 203 | ```yaml 204 | - service: linkplay.restore 205 | data: 206 | entity_id: media_player.sound_room1 207 | ``` 208 | You can specify multiple entity ids separated by comma or use `all` to run the service against. Currently the following state is being snapshotted/restored: 209 | - Volume 210 | - Input source 211 | - Webradio stream (as long as it's configured as an input source) 212 | - USB audio files playback (track will restart from the beginning) 213 | - Spotify: If the snapshot was taken with `switchinput` as `False`, it will recall the playlist, but playback may restart the same track or not, depends on Spotify settings. With `switchinput` as `True` it will do nothing, but you can resume playback from the Spotify integration in an automation (see example below). 214 | 215 | ## Service call examples 216 | 217 | Play a sound file located on an http server or a webradio stream: 218 | ```yaml 219 | - service: media_player.play_media 220 | data: 221 | entity_id: media_player.sound_room1 222 | media_content_id: 'http://icecast.streamserver.tld/mountpoint.mp3' 223 | media_content_type: url 224 | ``` 225 | 226 | Play the first sound file located on the local storage directly attached to the device (folder\files order seen by the chip seems to be alphabetic): 227 | ```yaml 228 | - service: media_player.play_media 229 | data: 230 | entity_id: media_player.sound_room1 231 | media_content_id: '1' 232 | media_content_type: music 233 | ``` 234 | 235 | Play a TTS (text-to-speech) announcement: 236 | ```yaml 237 | - service: tts.google_translate_say 238 | data: 239 | entity_id: media_player.sound_room1 240 | message: "Hanna has arrived home." 241 | language: en 242 | ``` 243 | If you experience that the announcement audio is cut off at the beginning, this happens because the player hardware needs some time to switch to playing out the stream. The only good solution for this is to add a configurable amount of silence at the beginning of the audio stream, I've modified [Mary TTS](https://github.com/nagyrobi/home-assistant-custom-components-marytts), [Google Translate](https://github.com/nagyrobi/home-assistant-custom-components-google_translate) and [VoiceRSS](https://github.com/nagyrobi/home-assistant-custom-components-voicerss) to do this, they can be installed manually as custom components ([even through HACS, manually](https://hacs.xyz/docs/faq/custom_repositories)). Linkplay modules seem to need about `800`ms of silence at the beginning of the stream in order for the first soundbits not to be cut down from the speech. 244 | 245 | ## Automation examples 246 | 247 | Select an input and set volume and unmute via an automation: 248 | ```yaml 249 | - alias: 'Switch to the line input of the TV when TV turns on' 250 | trigger: 251 | - platform: state 252 | entity_id: media_player.tv_room1 253 | to: 'on' 254 | action: 255 | - service: media_player.select_source 256 | data: 257 | entity_id: media_player.sound_room1 258 | source: 'TV sound' 259 | - service: media_player.volume_set 260 | data: 261 | entity_id: media_player.sound_room1 262 | volume_level: 1 263 | - service: media_player.volume_mute 264 | data: 265 | entity_id: media_player.sound_room1 266 | is_volume_muted: false 267 | ``` 268 | Note that you have to specify source names as you've set them in the configuration of the component. 269 | 270 | 271 | ## About Linkplay 272 | 273 | Linkplay is a smart audio chipset and module manufacturer. Their various module types share the same functionality across the whole platform and alow for native audio content playback from lots of sources, including local inputs, local files, Bluetooth, DNLA, Airplay and also web-based services like Icecast, Spotify, Tune-In, Deezer, Tidal etc. They allow setting up multiroom listening environments using either self-created wireless connections or relying on existing network infrastructure, for longer distances coverage. For more information visit https://linkplay.com/. 274 | There are quite a few manufacturers and devices that operate on the basis of Linkplay platform. Here are just some examples of the brands and models with A31 chipset: 275 | - **Arylic** (S50Pro, A50, Up2Stream), 276 | - **August** (WS300G), 277 | - **Audio Pro** (A10, A26, A36, A40, Addon C3/C5/C5A/C10/C-SUB, D-1, Drumfire, Link 1), 278 | - **Auna** (Intelligence Tube), 279 | - **Bauhn** (SoundMax 5), 280 | - **Bem** (Speaker Big Mo), 281 | - **Centaurus** (Flyears), 282 | - **Champion** (AWF320), 283 | - **COWIN** (DiDa, Thunder), 284 | - **Crystal Acoustics** (Crystal Audio), 285 | - **CVTE** (FD2140), 286 | - **Dayton Audio** (AERO), 287 | - **DOSS** (Deshi, Soundbox Mini, DOSS Assistant, Cloud Fox A1), 288 | - **DYON** (DYON Area Player), 289 | - **Edifier** (MA1), 290 | - **Energy Sistem** (Multiroom Tower Wi-Fi, Multiroom Portable Wi-Fi), 291 | - **FABRIQ** (Chorus, Riff), 292 | - **First Alert** (Onelink Safe & Sound), 293 | - **GE Sol** (C), 294 | - **GGMM** (E2 Wireless, E3 Wireless, E5 Wireless), 295 | - **GIEC** (Hi-Fi Smart Sound S1), 296 | - **Harman Kardon** (Allure), 297 | - **Hyundai** (Modern Oxygen Bar), 298 | - **iDeaUSA** (iDEaHome, Home Speaker, Mini Home Soundbar), 299 | - **iEAST Sonoé** (AudioCast M5, SoundStream, Stream Pro, StreamAmp AM160, StreamAmp i50B), 300 | - **iHome** (iAVS16), 301 | - **iLive** (Concierge, Platinum), 302 | - **iLuv** (Aud Air, Aud Click Shower, Aud Click), 303 | - **JAM Audio** (Voice, Symphony, Rhythm), 304 | - **JD** (CrazyBoa 2Face), 305 | - **KEiiD**, 306 | - **Lowes** (Showbox), 307 | - **Magnavox** (MSH315V), 308 | - **Medion** (MD43631, MedionX MD43259), 309 | - **Meidong** (Meidong 3119), 310 | - **MK** (MK Alexa Speaker), 311 | - **MÜZO** (Cobblestone), 312 | - **Naxa** (NAS-5003, NHS-5002, NAS-5001, NAS-5000), 313 | - **Nexum** (Memo), 314 | - **Omaker** (WoW), 315 | - **Omars** (Dogo), 316 | - **Polaroid** (PWF1001), 317 | - **Roxcore** (Roxcore), 318 | - **Sharper Image** (SWF1002), 319 | - **Shenzhen Renqing Technology Ltd** (ROCKLAVA), 320 | - **SoundBot** (SB600), 321 | - **SoundLogic** (Buddy), 322 | - **Stereoboommm** (MR200, MR300), 323 | - **Tibo** (Choros Tap), 324 | - **Tinman** (Smart JOJO), 325 | - **Venz** (A501), 326 | - **Uyesee** (AM160), 327 | - **Youzhuan** (Intelligent Music Ceiling), 328 | - **Zolo Audio** (Holo), 329 | - etc. 330 | 331 | ## Home Assistant component authors & contributors 332 | "@nicjo814", 333 | "@limych", 334 | "@nagyrobi" 335 | 336 | ## Home Assistant component License 337 | 338 | MIT License 339 | 340 | - Copyright (c) 2019 Niclas Berglind nicjo814 341 | - Copyright (c) 2019—2020 Andrey "Limych" Khrolenok 342 | - Copyright (c) 2020 nagyrobi Robert Horvath-Arkosi 343 | 344 | Permission is hereby granted, free of charge, to any person obtaining a copy 345 | of this software and associated documentation files (the "Software"), to deal 346 | in the Software without restriction, including without limitation the rights 347 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 348 | copies of the Software, and to permit persons to whom the Software is 349 | furnished to do so, subject to the following conditions: 350 | 351 | The above copyright notice and this permission notice shall be included in all 352 | copies or substantial portions of the Software. 353 | 354 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 355 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 356 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 357 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 358 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 359 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 360 | SOFTWARE. 361 | 362 | [forum-support]: https://community.home-assistant.io/t/linkplay-integration/33878 363 | -------------------------------------------------------------------------------- /custom_components/linkplay/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for LinkPlay based devices. 3 | 4 | For more details about this platform, please refer to the documentation at 5 | https://github.com/nagyrobi/home-assistant-custom-components-linkplay 6 | """ 7 | import logging 8 | import voluptuous as vol 9 | 10 | from homeassistant.const import ATTR_ENTITY_ID 11 | from homeassistant.helpers import config_validation as cv 12 | 13 | DOMAIN = 'linkplay' 14 | 15 | SERVICE_JOIN = 'join' 16 | SERVICE_UNJOIN = 'unjoin' 17 | SERVICE_PRESET = 'preset' 18 | SERVICE_CMD = 'command' 19 | SERVICE_SNAP = 'snapshot' 20 | SERVICE_REST = 'restore' 21 | SERVICE_LIST = 'get_tracks' 22 | SERVICE_PLAY = 'play_track' 23 | 24 | ATTR_MASTER = 'master' 25 | ATTR_PRESET = 'preset' 26 | ATTR_CMD = 'command' 27 | ATTR_NOTIF = 'notify' 28 | ATTR_SNAP = 'switchinput' 29 | ATTR_SELECT = 'input_select' 30 | ATTR_SOURCE = 'source' 31 | ATTR_TRACK = 'track' 32 | 33 | SERVICE_SCHEMA = vol.Schema({ 34 | vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids 35 | }) 36 | 37 | JOIN_SERVICE_SCHEMA = SERVICE_SCHEMA.extend({ 38 | vol.Required(ATTR_MASTER): cv.entity_id 39 | }) 40 | 41 | PRESET_BUTTON_SCHEMA = vol.Schema({ 42 | vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, 43 | vol.Required(ATTR_PRESET): cv.positive_int 44 | }) 45 | 46 | CMND_SERVICE_SCHEMA = vol.Schema({ 47 | vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, 48 | vol.Required(ATTR_CMD): cv.string, 49 | vol.Optional(ATTR_NOTIF, default=True): cv.boolean 50 | }) 51 | 52 | REST_SERVICE_SCHEMA = vol.Schema({ 53 | vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids 54 | }) 55 | 56 | SNAP_SERVICE_SCHEMA = vol.Schema({ 57 | vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, 58 | vol.Optional(ATTR_SNAP, default=True): cv.boolean 59 | }) 60 | 61 | PLYTRK_SERVICE_SCHEMA = vol.Schema({ 62 | vol.Required(ATTR_ENTITY_ID): cv.entity_id, 63 | vol.Required(ATTR_TRACK): cv.template 64 | }) 65 | 66 | _LOGGER = logging.getLogger(__name__) 67 | 68 | async def async_setup(hass, config): 69 | """Handle service configuration.""" 70 | 71 | async def async_service_handle(service): 72 | """Handle services.""" 73 | _LOGGER.debug("DOMAIN: %s, entities: %s", DOMAIN, str(hass.data[DOMAIN].entities)) 74 | _LOGGER.debug("Service_handle from id: %s", service.data.get(ATTR_ENTITY_ID)) 75 | entity_ids = service.data.get(ATTR_ENTITY_ID) 76 | entities = hass.data[DOMAIN].entities 77 | 78 | if entity_ids: 79 | if entity_ids == 'all': 80 | entity_ids = [e.entity_id for e in entities] 81 | entities = [e for e in entities if e.entity_id in entity_ids] 82 | 83 | if service.service == SERVICE_JOIN: 84 | master = [e for e in hass.data[DOMAIN].entities 85 | if e.entity_id == service.data[ATTR_MASTER]] 86 | if master: 87 | client_entities = [e for e in entities 88 | if e.entity_id != master[0].entity_id] 89 | _LOGGER.debug("**JOIN** set clients %s for master %s", 90 | [e.entity_id for e in client_entities], 91 | master[0].entity_id) 92 | await master[0].async_join(client_entities) 93 | 94 | elif service.service == SERVICE_UNJOIN: 95 | _LOGGER.debug("**UNJOIN** entities: %s", entities) 96 | masters = [entities for entities in entities 97 | if entities.is_master] 98 | if masters: 99 | for master in masters: 100 | await master.async_unjoin_all() 101 | else: 102 | for entity in entities: 103 | await entity.async_unjoin_me() 104 | 105 | elif service.service == SERVICE_PRESET: 106 | preset = service.data.get(ATTR_PRESET) 107 | for device in entities: 108 | if device.entity_id in entity_ids: 109 | _LOGGER.debug("**PRESET** entity: %s; preset: %s", device.entity_id, preset) 110 | await device.async_preset_button(preset) 111 | 112 | elif service.service == SERVICE_CMD: 113 | command = service.data.get(ATTR_CMD) 114 | notify = service.data.get(ATTR_NOTIF) 115 | for device in entities: 116 | if device.entity_id in entity_ids: 117 | _LOGGER.debug("**COMMAND** entity: %s; command: %s", device.entity_id, command) 118 | await device.async_execute_command(command, notify) 119 | 120 | elif service.service == SERVICE_SNAP: 121 | switchinput = service.data.get(ATTR_SNAP) 122 | for device in entities: 123 | if device.entity_id in entity_ids: 124 | _LOGGER.debug("**SNAPSHOT** entity: %s;", device.entity_id) 125 | await device.async_snapshot(switchinput) 126 | 127 | elif service.service == SERVICE_REST: 128 | for device in entities: 129 | if device.entity_id in entity_ids: 130 | _LOGGER.debug("**RESTORE** entity: %s;", device.entity_id) 131 | await device.async_restore() 132 | 133 | elif service.service == SERVICE_PLAY: 134 | track = service.data.get(ATTR_TRACK) 135 | for device in entities: 136 | if device.entity_id in entity_ids: 137 | _LOGGER.debug("**PLAY TRACK** entity: %s; track: %s", device.entity_id, track) 138 | await device.async_play_track(track) 139 | 140 | 141 | hass.services.async_register( 142 | DOMAIN, SERVICE_JOIN, async_service_handle, schema=JOIN_SERVICE_SCHEMA) 143 | hass.services.async_register( 144 | DOMAIN, SERVICE_UNJOIN, async_service_handle, schema=SERVICE_SCHEMA) 145 | hass.services.async_register( 146 | DOMAIN, SERVICE_PRESET, async_service_handle, schema=PRESET_BUTTON_SCHEMA) 147 | hass.services.async_register( 148 | DOMAIN, SERVICE_CMD, async_service_handle, schema=CMND_SERVICE_SCHEMA) 149 | hass.services.async_register( 150 | DOMAIN, SERVICE_SNAP, async_service_handle, schema=SNAP_SERVICE_SCHEMA) 151 | hass.services.async_register( 152 | DOMAIN, SERVICE_REST, async_service_handle, schema=REST_SERVICE_SCHEMA) 153 | hass.services.async_register( 154 | DOMAIN, SERVICE_PLAY, async_service_handle, schema=PLYTRK_SERVICE_SCHEMA) 155 | 156 | return True 157 | -------------------------------------------------------------------------------- /custom_components/linkplay/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "linkplay", 3 | "name": "Linkplay", 4 | "version":"3.2.3", 5 | "documentation": "https://github.com/nagyrobi/home-assistant-custom-components-linkplay", 6 | "issue_tracker": "https://github.com/nagyrobi/home-assistant-custom-components-linkplay/issues", 7 | "after_dependencies": ["http", "tts", "media_source"], 8 | "config_flow": false, 9 | "iot_class": "local_polling", 10 | "codeowners": [ 11 | "@nicjo814", 12 | "@limych", 13 | "@nagyrobi" 14 | ], 15 | "requirements": [ 16 | "async-upnp-client>=0.27", 17 | "validators~=0.12", 18 | "chardet>=4.0.0" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /custom_components/linkplay/media_player.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Linkplay based devices. 3 | 4 | For more details about this platform, please refer to the documentation at 5 | https://github.com/nagyrobi/home-assistant-custom-components-linkplay 6 | """ 7 | 8 | import asyncio 9 | from asyncio import CancelledError 10 | import async_timeout 11 | import voluptuous as vol 12 | 13 | from datetime import timedelta 14 | import logging 15 | import socket 16 | from json import loads, dumps 17 | import binascii 18 | import urllib.request 19 | import string 20 | import aiohttp 21 | 22 | from http import HTTPStatus 23 | from aiohttp.client_exceptions import ClientError 24 | from aiohttp.hdrs import CONNECTION, KEEP_ALIVE 25 | 26 | from async_upnp_client.client_factory import UpnpFactory 27 | from async_upnp_client.aiohttp import AiohttpRequester 28 | import xml.etree.ElementTree as ET 29 | 30 | import re 31 | import struct 32 | import chardet 33 | 34 | from homeassistant.util import Throttle 35 | from homeassistant.util.dt import utcnow 36 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 37 | import homeassistant.helpers.config_validation as cv 38 | 39 | from homeassistant.components.media_player import ( 40 | PLATFORM_SCHEMA, 41 | MediaPlayerEntity, 42 | MediaPlayerEntityFeature, 43 | MediaPlayerDeviceClass, 44 | BrowseMedia, 45 | ) 46 | 47 | from homeassistant.components import media_source 48 | from homeassistant.components.media_player.browse_media import ( 49 | async_process_play_media_url, 50 | ) 51 | 52 | from homeassistant.components.media_player.const import ( 53 | ATTR_GROUP_MEMBERS, 54 | MEDIA_TYPE_MUSIC, 55 | MEDIA_TYPE_URL, 56 | MEDIA_TYPE_TRACK, 57 | MEDIA_CLASS_DIRECTORY, 58 | MEDIA_CLASS_MUSIC, 59 | REPEAT_MODE_ALL, 60 | REPEAT_MODE_OFF, 61 | REPEAT_MODE_ONE, 62 | ) 63 | from homeassistant.const import ( 64 | ATTR_ENTITY_ID, 65 | ATTR_DEVICE_CLASS, 66 | CONF_HOST, 67 | CONF_NAME, 68 | CONF_PORT, 69 | CONF_PROTOCOL, 70 | STATE_IDLE, 71 | STATE_PAUSED, 72 | STATE_PLAYING, 73 | STATE_UNKNOWN, 74 | STATE_UNAVAILABLE, 75 | ) 76 | 77 | from . import DOMAIN, ATTR_MASTER 78 | 79 | _LOGGER = logging.getLogger(__name__) 80 | 81 | ICON_DEFAULT = 'mdi:speaker' 82 | ICON_PLAYING = 'mdi:speaker-wireless' 83 | ICON_MUTED = 'mdi:speaker-off' 84 | ICON_MULTIROOM = 'mdi:speaker-multiple' 85 | ICON_BLUETOOTH = 'mdi:speaker-bluetooth' 86 | ICON_PUSHSTREAM = 'mdi:cast-audio' 87 | ICON_TTS = 'mdi:speaker-message' 88 | 89 | ATTR_SLAVE = 'slave' 90 | ATTR_LINKPLAY_GROUP = 'linkplay_group' 91 | ATTR_FWVER = 'firmware' 92 | ATTR_TRCNT = 'tracks_local' 93 | ATTR_TRCRT = 'track_current' 94 | ATTR_STURI = 'stream_uri' 95 | ATTR_UUID = 'uuid' 96 | ATTR_TTS = 'tts_active' 97 | ATTR_SNAPSHOT = 'snapshot_active' 98 | ATTR_SNAPSPOT = 'snapshot_spotify' 99 | ATTR_DEBUG = 'debug_info' 100 | 101 | CONF_NAME = 'name' 102 | CONF_LASTFM_API_KEY = 'lastfm_api_key' 103 | CONF_SOURCES = 'sources' 104 | CONF_COMMONSOURCES = 'common_sources' 105 | CONF_ICECAST_METADATA = 'icecast_metadata' 106 | CONF_MULTIROOM_WIFIDIRECT = 'multiroom_wifidirect' 107 | CONF_VOLUME_STEP = 'volume_step' 108 | CONF_LEDOFF = 'led_off' 109 | CONF_UUID = 'uuid' 110 | 111 | DEFAULT_ICECAST_UPDATE = 'StationName' 112 | DEFAULT_MULTIROOM_WIFIDIRECT = False 113 | DEFAULT_LEDOFF = False 114 | DEFAULT_VOLUME_STEP = 5 115 | 116 | DEBUGSTR_ATTR = True 117 | LASTFM_API_BASE = 'http://ws.audioscrobbler.com/2.0/?method=' 118 | MAX_VOL = 100 119 | FW_MROOM_RTR_MIN = '4.2.8020' 120 | FW_RAKOIT_UART_MIN = '4.2.9326' 121 | FW_SLOW_STREAMS = '4.6' 122 | ROOTDIR_USB = '/media/sda1/' 123 | UUID_ARYLIC = 'FF31F09E' 124 | TCPPORT = 8899 125 | UPNP_TIMEOUT = 2 126 | API_TIMEOUT = 2 127 | SCAN_INTERVAL = timedelta(seconds=3) 128 | ICE_THROTTLE = timedelta(seconds=45) 129 | LFM_THROTTLE = timedelta(seconds=4) 130 | UNA_THROTTLE = timedelta(seconds=20) 131 | MROOM_UJWDIR = timedelta(seconds=20) 132 | MROOM_UJWROU = timedelta(seconds=3) 133 | SPOTIFY_PAUSED_TIMEOUT = timedelta(seconds=300) 134 | AUTOIDLE_STATE_TIMEOUT = timedelta(seconds=1) 135 | #PARALLEL_UPDATES = 0 136 | 137 | CUT_EXTENSIONS = ['mp3', 'mp2', 'm2a', 'mpg', 'wav', 'aac', 'flac', 'flc', 'm4a', 'ape', 'wma', 'ac3', 'ogg'] 138 | 139 | SOUND_MODES = {'0': 'Normal', '1': 'Classic', '2': 'Pop', '3': 'Jazz', '4': 'Vocal'} 140 | 141 | SOURCES = {'bluetooth': 'Bluetooth', 142 | 'line-in': 'Line-in', 143 | 'line-in2': 'Line-in 2', 144 | 'optical': 'Optical', 145 | 'co-axial': 'Coaxial', 146 | 'HDMI': 'HDMI', 147 | 'udisk': 'USB disk', 148 | 'TFcard': 'SD card', 149 | 'RCA': 'RCA', 150 | 'XLR': 'XLR', 151 | 'FM': 'FM', 152 | 'cd': 'CD', 153 | 'PCUSB': 'USB DAC'} 154 | 155 | SOURCES_MAP = {'-1': 'Idle', 156 | '0': 'Idle', 157 | '1': 'Airplay', 158 | '2': 'DLNA', 159 | '3': 'QPlay', 160 | '10': 'Network', 161 | '11': 'udisk', 162 | '16': 'TFcard', 163 | '20': 'API', 164 | '21': 'udisk', 165 | '30': 'Alarm', 166 | '31': 'Spotify', 167 | '40': 'line-in', 168 | '41': 'bluetooth', 169 | '43': 'optical', 170 | '44': 'RCA', 171 | '45': 'co-axial', 172 | '46': 'FM', 173 | '47': 'line-in2', 174 | '48': 'XLR', 175 | '49': 'HDMI', 176 | '50': 'cd', 177 | '51': 'USB DAC', 178 | '52': 'TFcard', 179 | '60': 'Talk', 180 | '99': 'Idle'} 181 | 182 | SOURCES_LIVEIN = ['-1', '0', '40', '41', '43', '44', '45', '46', '47', '48', '49', '50', '51', '99'] 183 | SOURCES_STREAM = ['1', '2', '3', '10', '30'] 184 | SOURCES_LOCALF = ['11', '16', '20', '21', '52', '60'] 185 | 186 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 187 | { 188 | vol.Required(CONF_HOST): cv.string, 189 | vol.Required(CONF_NAME): cv.string, 190 | vol.Optional(CONF_PROTOCOL): vol.In(['http', 'https']), 191 | vol.Optional(CONF_ICECAST_METADATA, default=DEFAULT_ICECAST_UPDATE): vol.In(['Off', 'StationName', 'StationNameSongTitle']), 192 | vol.Optional(CONF_MULTIROOM_WIFIDIRECT, default=DEFAULT_MULTIROOM_WIFIDIRECT): cv.boolean, 193 | vol.Optional(CONF_LEDOFF, default=DEFAULT_LEDOFF): cv.boolean, 194 | vol.Optional(CONF_SOURCES): cv.ensure_list, 195 | vol.Optional(CONF_COMMONSOURCES): cv.ensure_list, 196 | vol.Optional(CONF_LASTFM_API_KEY): cv.string, 197 | vol.Optional(CONF_UUID, default=''): cv.string, 198 | vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): vol.All(int, vol.Range(min=1, max=25)), 199 | } 200 | ) 201 | 202 | class LinkPlayData: 203 | """Storage class for platform global data.""" 204 | def __init__(self): 205 | """Initialize the data.""" 206 | self.entities = [] 207 | 208 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 209 | """Set up the LinkPlayDevice platform.""" 210 | 211 | if DOMAIN not in hass.data: 212 | hass.data[DOMAIN] = LinkPlayData() 213 | 214 | name = config.get(CONF_NAME) 215 | host = config.get(CONF_HOST) 216 | protocol = config.get(CONF_PROTOCOL) 217 | sources = config.get(CONF_SOURCES) 218 | common_sources = config.get(CONF_COMMONSOURCES) 219 | icecast_metadata = config.get(CONF_ICECAST_METADATA) 220 | multiroom_wifidirect = config.get(CONF_MULTIROOM_WIFIDIRECT) 221 | led_off = config.get(CONF_LEDOFF) 222 | volume_step = config.get(CONF_VOLUME_STEP) 223 | lastfm_api_key = config.get(CONF_LASTFM_API_KEY) 224 | uuid = config.get(CONF_UUID) 225 | 226 | default_protocol = False 227 | if protocol is None: 228 | protocol = "http" 229 | default_protocol = True 230 | 231 | state = STATE_IDLE 232 | 233 | websession = async_get_clientsession(hass) 234 | response = None 235 | 236 | try: 237 | initurl = "{}://{}/httpapi.asp?command=getStatus" 238 | response = await websession.get(initurl.format(protocol,host)) 239 | 240 | except (asyncio.TimeoutError, aiohttp.ClientError) as error: 241 | if default_protocol: 242 | try: 243 | protocol = "https" 244 | initurl = "{}://{}/httpapi.asp?command=getStatusEx" 245 | response = await websession.get(initurl.format(protocol,host), ssl=False) 246 | 247 | except (asyncio.TimeoutError, aiohttp.ClientError) as error: 248 | _LOGGER.warning( 249 | "Failed communicating with LinkPlayDevice (start) '%s': uuid: %s %s", host, uuid, type(error) 250 | ) 251 | state = STATE_UNAVAILABLE 252 | else: 253 | _LOGGER.warning( 254 | "Failed communicating with LinkPlayDevice (start) '%s': uuid: %s %s", host, uuid, type(error) 255 | ) 256 | state = STATE_UNAVAILABLE 257 | 258 | if response and response.status == HTTPStatus.OK: 259 | data = await response.json(content_type=None) 260 | _LOGGER.debug("HOST: %s DATA response: %s", host, data) 261 | 262 | if 'uuid' in data: 263 | uuid = data['uuid'] 264 | 265 | if 'DeviceName' in data and name == None: 266 | name = data['DeviceName'] 267 | 268 | else: 269 | _LOGGER.warning( 270 | "Get Status UUID failed, response code: %s Full message: %s", 271 | response.status if response is not None else "Unknown", 272 | response, 273 | ) 274 | state = STATE_UNAVAILABLE 275 | 276 | linkplay = LinkPlayDevice(name, 277 | host, 278 | protocol, 279 | sources, 280 | common_sources, 281 | icecast_metadata, 282 | multiroom_wifidirect, 283 | led_off, 284 | volume_step, 285 | lastfm_api_key, 286 | uuid, 287 | state, 288 | hass) 289 | 290 | async_add_entities([linkplay]) 291 | 292 | class LinkPlayDevice(MediaPlayerEntity): 293 | """LinkPlayDevice Player Object.""" 294 | 295 | def __init__(self, 296 | name, 297 | host, 298 | protocol, 299 | sources, 300 | common_sources, 301 | icecast_metadata, 302 | multiroom_wifidirect, 303 | led_off, 304 | volume_step, 305 | lastfm_api_key, 306 | uuid, 307 | state, 308 | hass): 309 | """Initialize the media player.""" 310 | self._uuid = uuid 311 | self._fw_ver = '1.0.0' 312 | self._mcu_ver = '' 313 | requester = AiohttpRequester(UPNP_TIMEOUT) 314 | self._factory = UpnpFactory(requester) 315 | self._upnp_device = None 316 | self._service = None 317 | self._features = None 318 | self._preset_key = 4 319 | self._name = name 320 | self._host = host 321 | self._protocol = protocol 322 | self._icon = ICON_DEFAULT 323 | self._state = state 324 | self._volume = 0 325 | self._volume_step = volume_step 326 | self._led_off = led_off 327 | self._fadevol = False 328 | self._source = None 329 | self._prev_source = None 330 | if sources is not None and sources != {}: 331 | self._source_list = loads(dumps(sources).strip('[]')) 332 | else: 333 | self._source_list = SOURCES.copy() 334 | if common_sources is not None and common_sources != {}: 335 | commonsources = loads(dumps(common_sources).strip('[]')) 336 | localsources = self._source_list 337 | self._source_list = {**localsources, **commonsources} 338 | self._sound_mode = None 339 | self._muted = False 340 | self._playhead_position = 0 341 | self._duration = 0 342 | self._position_updated_at = None 343 | self._spotify_paused_at = None 344 | self._idletime_updated_at = None 345 | self._shuffle = False 346 | self._repeat = REPEAT_MODE_OFF 347 | self._media_album = None 348 | self._media_artist = None 349 | self._media_prev_artist = None 350 | self._media_title = None 351 | self._media_prev_title = None 352 | self._media_image_url = None 353 | self._media_uri = None 354 | self._media_uri_final = None 355 | self._media_source_uri = None 356 | self._nometa = False 357 | self._player_statdata = {} 358 | self._lastfm_api_key = lastfm_api_key 359 | self._first_update = True 360 | self._slave_mode = False 361 | self._slave_ip = None 362 | self._trackq = [] 363 | self._trackc = None 364 | self._master = None 365 | self._is_master = False 366 | self._wifi_channel = None 367 | self._ssid = None 368 | self._playing_localfile = True 369 | self._playing_stream = False 370 | self._playing_liveinput = False 371 | self._playing_spotify = False 372 | self._playing_webplaylist = False 373 | self._playing_tts = False 374 | self._playing_mediabrowser = False 375 | self._slave_list = None 376 | self._multiroom_wifidirect = multiroom_wifidirect 377 | self._multiroom_group = [] 378 | self._multiroom_prevsrc = None 379 | self._multiroom_unjoinat = None 380 | self._wait_for_mcu = 0 381 | self._new_song = True 382 | self._unav_throttle = False 383 | self._icecast_name = None 384 | self._icecast_meta = icecast_metadata 385 | self._ice_skip_throt = False 386 | self._snapshot_active = False 387 | self._snap_source = None 388 | self._snap_uri = None 389 | self._snap_state = STATE_UNKNOWN 390 | self._snap_volume = 0 391 | self._snap_spotify = False 392 | self._snap_spotify_volumeonly = False 393 | self._snap_nometa = False 394 | self._snap_playing_mediabrowser = False 395 | self._snap_media_source_uri = None 396 | self._snap_seek = False 397 | self._snap_playhead_position = 0 398 | 399 | async def async_added_to_hass(self): 400 | """Record entity.""" 401 | self.hass.data[DOMAIN].entities.append(self) 402 | 403 | async def call_linkplay_httpapi(self, cmd, jsn, protocol = None): 404 | """Get the latest data from HTTPAPI service.""" 405 | if protocol is None and self._protocol is None: 406 | _LOGGER.warning("Protocol not known. Skipping communication with LinkPlayDevice '%s'", self._name) 407 | return False 408 | 409 | url = "{}://{}/httpapi.asp?command={}".format(self._protocol if protocol is None else protocol, self._host, cmd) 410 | 411 | if self._first_update: 412 | timeout = 10 413 | else: 414 | timeout = API_TIMEOUT 415 | 416 | try: 417 | websession = async_get_clientsession(self.hass) 418 | async with async_timeout.timeout(timeout): 419 | response = await websession.get(url, ssl=False) 420 | 421 | except (asyncio.TimeoutError, aiohttp.ClientError) as error: 422 | _LOGGER.warning( 423 | "Failed communicating with LinkPlayDevice (httpapi) '%s': %s", self._name, type(error) 424 | ) 425 | return False 426 | 427 | if response.status == HTTPStatus.OK: 428 | if jsn: 429 | data = await response.json(content_type=None) 430 | else: 431 | data = await response.text() 432 | _LOGGER.debug("For: %s cmd: %s resp: %s", self._name, cmd, data) 433 | else: 434 | _LOGGER.error( 435 | "For: %s (%s) Get failed, response code: %s Full message: %s", 436 | self._name, 437 | self._host, 438 | response.status, 439 | response, 440 | ) 441 | return False 442 | 443 | return data 444 | 445 | async def call_linkplay_tcpuart(self, cmd): 446 | """Get the latest data from TCP UART service.""" 447 | LENC = format(len(cmd), '02x') 448 | HED1 = '18 96 18 20 ' 449 | HED2 = ' 00 00 00 c1 02 00 00 00 00 00 00 00 00 00 00 ' 450 | CMHX = ' '.join(hex(ord(c))[2:] for c in cmd) 451 | data = None 452 | _LOGGER.debug("For: %s Sending to %s TCP UART command: %s", self._name, self._host, cmd) 453 | try: 454 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 455 | s.settimeout(API_TIMEOUT) 456 | s.connect((self._host, TCPPORT)) 457 | s.send(bytes.fromhex(HED1 + LENC + HED2 + CMHX)) 458 | data = str(repr(s.recv(1024))).encode().decode("unicode-escape") 459 | 460 | pos = data.find("AXX") 461 | if pos == -1: 462 | pos = data.find("MCU") 463 | 464 | data = data[pos:(len(data)-2)] 465 | _LOGGER.debug("For: %s Received from %s TCP UART command result: %s", self._name, self._host, data) 466 | try: 467 | s.close() 468 | except: 469 | pass 470 | 471 | except socket.error as ex: 472 | _LOGGER.debug("For: %s Error sending TCP UART command: %s with %s", self._name, cmd, ex) 473 | data = None 474 | 475 | return data 476 | 477 | @Throttle(UNA_THROTTLE) 478 | async def async_get_status(self): 479 | resp = await self.call_linkplay_httpapi("getPlayerStatus", True) 480 | if resp is False: 481 | _LOGGER.debug('Unable to connect to device: %s, %s', self.entity_id, self._name) 482 | self._state = STATE_UNAVAILABLE 483 | self._unav_throttle = True 484 | self._wait_for_mcu = 0 485 | self._playhead_position = None 486 | self._duration = None 487 | self._position_updated_at = None 488 | self._media_title = None 489 | self._media_artist = None 490 | self._media_album = None 491 | self._media_image_url = None 492 | self._media_uri = None 493 | self._media_uri_final = None 494 | self._media_source_uri = None 495 | self._playing_mediabrowser = False 496 | self._playing_stream = False 497 | self._icecast_name = None 498 | self._source = None 499 | self._upnp_device = None 500 | self._first_update = True 501 | self._slave_mode = False 502 | self._is_master = False 503 | self._player_statdata = None 504 | return 505 | self._player_statdata = resp.copy() 506 | 507 | async def async_trigger_schedule_update(self, before): 508 | await self.async_schedule_update_ha_state(before) 509 | 510 | async def async_update(self): 511 | """Update state.""" 512 | 513 | # If we couldn't determine our protocol on startup, then attempt to do it now as our speaker might be available 514 | if self._protocol is None: 515 | device_status = await self.call_linkplay_httpapi("getStatusEx", True, "https") 516 | if device_status is not None and device_status != False: 517 | self._protocol = "https" 518 | else: 519 | device_status = await self.call_linkplay_httpapi("getStatus", True, "http") 520 | if device_status is not None and device_status != False: 521 | self._protocol = "http" 522 | else: 523 | return False 524 | 525 | #_LOGGER.debug("01 Start update %s, %s", self.entity_id, self._name) 526 | if self._master is None: 527 | self._slave_mode = False 528 | 529 | if self._slave_mode: # or self._snapshot_active: 530 | return True 531 | 532 | if self._multiroom_unjoinat is not None: 533 | #_LOGGER.debug("01 Update mroomunjoinat %s, %s", self.entity_id, self._name) 534 | if self._multiroom_wifidirect: 535 | waittim = MROOM_UJWDIR 536 | else: 537 | waittim = MROOM_UJWROU 538 | 539 | if utcnow() <= (self._multiroom_unjoinat + waittim): 540 | self._source = None 541 | self._media_title = None 542 | self._media_artist = None 543 | self._media_uri = None 544 | self._media_uri_final = None 545 | self._media_image_url = None 546 | self._state = STATE_IDLE 547 | return True 548 | else: 549 | self._multiroom_unjoinat = None 550 | self._playhead_position = 0 551 | self._duration = 0 552 | self._position_updated_at = utcnow() 553 | self._idletime_updated_at = self._position_updated_at 554 | # await self.async_restore_previous_source() 555 | await self.async_select_source(self._multiroom_prevsrc) 556 | self._multiroom_prevsrc = None 557 | return True 558 | 559 | # if self._wait_for_mcu > 0: # have wait for the hardware unit to finish processing command, otherwise some reported status values will be incorrect 560 | # await asyncio.sleep(self._wait_for_mcu) 561 | # self._wait_for_mcu = 0 562 | 563 | if self._unav_throttle: 564 | await self.async_get_status() 565 | else: 566 | await self.async_get_status(no_throttle=True) 567 | 568 | if self._player_statdata is None: 569 | _LOGGER.debug("First update/No response from api: %s, %s", self.entity_id, self._player_statdata) 570 | return 571 | 572 | if isinstance(self._player_statdata, dict): 573 | self._unav_throttle = False 574 | if self._first_update or (self._state == STATE_UNAVAILABLE or self._multiroom_wifidirect): 575 | #_LOGGER.debug("03 Update first time getStatus %s, %s", self.entity_id, self._name) 576 | if self._protocol == "https": 577 | device_status = await self.call_linkplay_httpapi("getStatusEx", True) 578 | else: 579 | device_status = await self.call_linkplay_httpapi("getStatus", True) 580 | if device_status is not None: 581 | if isinstance(device_status, dict): 582 | if self._state == STATE_UNAVAILABLE: 583 | self._state = STATE_IDLE 584 | self._wifi_channel = device_status['WifiChannel'] 585 | self._ssid = binascii.hexlify(device_status['ssid'].encode('utf-8')) 586 | self._ssid = self._ssid.decode() 587 | 588 | try: 589 | self._uuid = device_status['uuid'] 590 | except KeyError: 591 | pass 592 | 593 | try: 594 | self._name = device_status['DeviceName'] 595 | except KeyError: 596 | pass 597 | 598 | try: 599 | self._fw_ver = device_status['firmware'] 600 | except KeyError: 601 | self._fw_ver = '1.0.0' 602 | 603 | try: 604 | self._mcu_ver = device_status['mcu_ver'] 605 | except KeyError: 606 | self._mcu_ver = '' 607 | 608 | try: 609 | self._preset_key = int(device_status['preset_key']) 610 | except KeyError: 611 | self._preset_key = 4 612 | 613 | if self._led_off and self._uuid != '': 614 | if self._uuid.startswith(UUID_ARYLIC) and self._fwvercheck(self._fw_ver) >= self._fwvercheck(FW_RAKOIT_UART_MIN): 615 | value = await self.call_linkplay_tcpuart('MCU+PAS+RAKOIT:LED:0&') 616 | _LOGGER.debug("LED turn off: %s, %s, response: %s", self.entity_id, self._name, value) 617 | 618 | if not self._multiroom_wifidirect and self._fw_ver: 619 | if self._fwvercheck(self._fw_ver) < self._fwvercheck(FW_MROOM_RTR_MIN): 620 | self._multiroom_wifidirect = True 621 | 622 | if self._upnp_device is None: # and self._name is not None: 623 | url = "http://{0}:49152/description.xml".format(self._host) 624 | try: 625 | self._upnp_device = await self._factory.async_create_device(url) 626 | except: 627 | _LOGGER.warning( 628 | "Failed communicating with LinkPlayDevice (UPnP) '%s': %s", self._name, type(error) 629 | ) 630 | 631 | if self._first_update: 632 | self._duration = 0 633 | self._playhead_position = 0 634 | self._idletime_updated_at = utcnow() 635 | if "udisk" in self._source_list: 636 | await self.async_tracklist_via_upnp("USB") 637 | self._first_update = False 638 | 639 | self._position_updated_at = utcnow() 640 | 641 | if self._player_statdata['type'] == '0': 642 | self._slave_mode = False 643 | 644 | if self._multiroom_group == []: 645 | self._slave_mode = False 646 | self._is_master = False 647 | self._master = None 648 | 649 | if not self._is_master: 650 | self._master = None 651 | self._multiroom_group = [] 652 | 653 | #_LOGGER.debug("04 Update VOL, Shuffle, Repeat, STATE %s, %s", self.entity_id, self._name) 654 | self._volume = self._player_statdata['vol'] 655 | self._muted = bool(int(self._player_statdata['mute'])) 656 | self._sound_mode = SOUND_MODES.get(self._player_statdata['eq']) 657 | 658 | self._shuffle = { 659 | '2': True, 660 | '3': True, 661 | '5': True, 662 | }.get(self._player_statdata['loop'], False) 663 | 664 | self._repeat = { 665 | '0': REPEAT_MODE_ALL, 666 | '1': REPEAT_MODE_ONE, 667 | '2': REPEAT_MODE_ALL, 668 | '5': REPEAT_MODE_ONE, 669 | }.get(self._player_statdata['loop'], REPEAT_MODE_OFF) 670 | 671 | # self._state = { 672 | # 'stop': STATE_IDLE, 673 | # 'load': STATE_PLAYING, 674 | # 'play': STATE_PLAYING, 675 | # 'pause': STATE_PAUSED, 676 | # }.get(self._player_statdata['status'], STATE_IDLE) 677 | 678 | if (self._player_statdata['mode'] in ['-1', '0', '99'] or self._player_statdata['status'] == 'stop'): 679 | if utcnow() >= (self._idletime_updated_at + AUTOIDLE_STATE_TIMEOUT): 680 | self._state = STATE_IDLE 681 | #_LOGGER.debug("05 DETECTED %s, %s", self.entity_id, self._state) 682 | elif self._player_statdata['status'] in ['play', 'load']: 683 | self._state = STATE_PLAYING 684 | #_LOGGER.debug("05 DETECTED %s, %s", self.entity_id, self._state) 685 | elif self._player_statdata['status'] == 'pause': 686 | self._state = STATE_PAUSED 687 | #_LOGGER.debug("05 DETECTED %s, %s", self.entity_id, self._state) 688 | 689 | if self._state in [STATE_PLAYING, STATE_PAUSED]: 690 | self._duration = int(int(self._player_statdata['totlen']) / 1000) 691 | self._playhead_position = int(int(self._player_statdata['curpos']) / 1000) 692 | #_LOGGER.debug("04 Update DUR, POS %s, %s, %s, %s, %s", self.entity_id, self._name, self._state, self._duration, self._playhead_position) 693 | else: 694 | self._duration = 0 695 | self._playhead_position = 0 696 | 697 | #_LOGGER.debug("05 Update self._playing_whatever %s, %s", self.entity_id, self._name) 698 | self._playing_spotify = bool(self._player_statdata['mode'] == '31') 699 | self._playing_liveinput = self._player_statdata['mode'] in SOURCES_LIVEIN 700 | self._playing_stream = self._player_statdata['mode'] in SOURCES_STREAM 701 | self._playing_localfile = self._player_statdata['mode'] in SOURCES_LOCALF 702 | 703 | if bool(self._player_statdata['mode'] != '10'): 704 | #self._playing_tts = False 705 | self._playing_mediabrowser = False 706 | 707 | if not (self._playing_liveinput or self._playing_stream or self._playing_spotify): 708 | self._playing_localfile = True 709 | 710 | try: 711 | if self._playing_stream and self._player_statdata['uri'] != "": 712 | _LOGGER.debug("06 Update URI final detect %s, %s", self.entity_id, self._name) 713 | try: 714 | self._media_uri_final = str(bytearray.fromhex(self._player_statdata['uri']).decode('utf-8')) 715 | except ValueError: 716 | self._media_uri_final = self._player_statdata['uri'] 717 | if not self._media_uri: 718 | self._media_uri = self._media_uri_final 719 | except KeyError: 720 | pass 721 | 722 | if self._media_uri: 723 | #_LOGGER.debug("07 Detect CDN %s, %s", self.entity_id, self._name) 724 | # Detect web music service by their CDN subdomains in the URL 725 | # Tidal, Deezer 726 | self._playing_webplaylist = \ 727 | bool(self._media_uri.find('audio.tidal.') != -1) or \ 728 | bool(self._media_uri.find('.dzcdn.') != -1) or \ 729 | bool(self._media_uri.find('.deezer.') != -1) 730 | 731 | if not self._playing_webplaylist: 732 | #_LOGGER.debug("07 Set Name to Source: %s, %s", self.entity_id, self._name) 733 | source_t = SOURCES_MAP.get(self._player_statdata['mode'], 'Network') 734 | source_n = None 735 | if source_t == 'Network': 736 | if self._media_uri: 737 | source_n = self._source_list.get(self._media_uri, 'Network') 738 | else: 739 | source_n = self._source_list.get(source_t, None) 740 | 741 | if source_n != None: 742 | self._source = source_n 743 | else: 744 | self._source = source_t 745 | else: 746 | self._source = 'Web playlist' 747 | 748 | if self._source != 'Network' and not (self._playing_stream or self._playing_localfile or self._playing_spotify): 749 | #_LOGGER.debug("08 Line Inputs: %s, %s", self.entity_id, self._name) 750 | if self._source == 'Idle': 751 | self._state = STATE_IDLE 752 | self._media_title = None 753 | else: 754 | self._state = STATE_PLAYING 755 | self._media_title = self._source 756 | 757 | self._media_artist = None 758 | self._media_album = None 759 | self._media_image_url = None 760 | self._icecast_name = None 761 | 762 | if self._player_statdata['mode'] in ['1', '2', '3']: 763 | #_LOGGER.debug("08 Line Inputs name playing: %s, %s", self.entity_id, self._name) 764 | self._state = STATE_PLAYING 765 | self._media_title = self._source 766 | 767 | if self._playing_spotify and self._state == STATE_IDLE: 768 | self._source = None 769 | 770 | if self._spotify_paused_at != None: 771 | if utcnow() >= (self._spotify_paused_at + SPOTIFY_PAUSED_TIMEOUT): 772 | # Prevent sticking in Pause mode for a long time (Spotify doesn't have a stop button on the app) 773 | await self.async_media_stop() 774 | return 775 | 776 | if self._player_statdata['mode'] in ['11', '16'] and len(self._trackq) <= 0: 777 | if int(self._player_statdata['curpos']) > 6000 and self._state == STATE_PLAYING: 778 | await self.async_tracklist_via_upnp("USB") 779 | 780 | if self._playing_spotify: 781 | #_LOGGER.debug("09 it's playing spotifty: %s, %s", self.entity_id, self._name) 782 | if self._state != STATE_IDLE: 783 | await self.async_update_via_upnp() 784 | if self._state == STATE_PAUSED: 785 | if self._spotify_paused_at == None: 786 | self._spotify_paused_at = utcnow() 787 | else: 788 | self._spotify_paused_at = None 789 | # else: 790 | # self._trackc = None 791 | # self._media_uri_final = None 792 | 793 | elif self._playing_webplaylist: 794 | if self._state != STATE_IDLE: 795 | self.async_update_via_upnp() 796 | 797 | else: 798 | #_LOGGER.debug("09 it's playing something else: %s, %s", self.entity_id, self._name) 799 | self._spotify_paused_at = None 800 | if self._state not in [STATE_PLAYING, STATE_PAUSED]: 801 | self._media_title = None 802 | self._media_artist = None 803 | self._media_album = None 804 | self._media_image_url = None 805 | self._icecast_name = None 806 | self._playing_tts = False 807 | 808 | if self._playing_localfile and self._state in [STATE_PLAYING, STATE_PAUSED] and not self._playing_tts: 809 | #_LOGGER.debug("10 Update async_get_playerstatus_metadata FILE %s, %s", self.entity_id, self._name) 810 | await self.async_get_playerstatus_metadata(self._player_statdata) 811 | 812 | if self._media_title is not None and self._media_artist is None: 813 | querywords = self._media_title.split('.') 814 | resultwords = [word for word in querywords if word.lower() not in CUT_EXTENSIONS] 815 | title = ' '.join(resultwords) 816 | title.replace('_', ' ') 817 | if title.find(' - ') != -1: 818 | titles = title.split(' - ') 819 | self._media_artist = string.capwords(titles[0].strip().strip('-')) 820 | self._media_title = string.capwords(titles[1].strip().strip('-')) 821 | else: 822 | self._media_title = string.capwords(title.strip().strip('-')) 823 | else: 824 | self._media_title = self._source 825 | 826 | elif self._state == STATE_PLAYING and self._media_uri and int(self._player_statdata['totlen']) > 0 and not self._snapshot_active and not self._playing_tts and not self._playing_mediabrowser: 827 | #_LOGGER.debug("10 Update async_get_playerstatus_metadata media_URI %s, %s", self.entity_id, self._name) 828 | if not self._nometa: 829 | await self.async_get_playerstatus_metadata(self._player_statdata) 830 | 831 | elif self._state == STATE_PLAYING and self._media_uri_final and int(self._player_statdata['totlen']) <= 0 and not self._snapshot_active and not self._playing_tts: 832 | #_LOGGER.debug("10 Update async_update_from_icecast FILE %s, %s", self.entity_id, self._name) 833 | if self._ice_skip_throt: 834 | await self.async_update_from_icecast(no_throttle=True) 835 | self._ice_skip_throt = False 836 | else: 837 | await self.async_update_from_icecast() 838 | 839 | elif self._state == STATE_PLAYING and self._playing_mediabrowser and self._media_source_uri is not None: 840 | if not self._nometa: 841 | await self.async_get_local_mediasource_metadata_from_path() 842 | 843 | self._new_song = await self.async_is_playing_new_track() 844 | if self._lastfm_api_key is not None and self._new_song: 845 | #_LOGGER.debug("11 Update async_get_lastfm_coverart %s, %s", self.entity_id, self._name) 846 | await self.async_get_lastfm_coverart() 847 | 848 | self._media_prev_artist = self._media_artist 849 | self._media_prev_title = self._media_title 850 | 851 | else: 852 | _LOGGER.error("Erroneous JSON during update and process self._player_statdata: %s, %s", self.entity_id, self._name) 853 | 854 | 855 | # Get multiroom slave information # 856 | 857 | slave_list = await self.call_linkplay_httpapi("multiroom:getSlaveList", True) 858 | if slave_list is None: 859 | self._is_master = False 860 | self._slave_list = None 861 | self._multiroom_group = [] 862 | return True 863 | 864 | self._slave_list = [] 865 | self._multiroom_group = [] 866 | if isinstance(slave_list, dict): 867 | if int(slave_list['slaves']) > 0: 868 | self._multiroom_group.append(self.entity_id) 869 | self._is_master = True 870 | for slave in slave_list['slave_list']: 871 | for device in self.hass.data[DOMAIN].entities: 872 | if device._name == slave['name']: 873 | self._multiroom_group.append(device.entity_id) 874 | await device.async_set_master(self) 875 | await device.async_set_is_master(False) 876 | await device.async_set_slave_mode(True) 877 | await device.async_set_media_title(self._media_title) 878 | await device.async_set_media_artist(self._media_artist) 879 | await device.async_set_volume(slave['volume']) 880 | #await device.async_set_muted(slave['mute']) 881 | await device.async_set_state(self.state) 882 | await device.async_set_slave_ip(slave['ip']) 883 | await device.async_set_media_image_url(self._media_image_url) 884 | await device.async_set_playhead_position(self.media_position) 885 | await device.async_set_duration(self.media_duration) 886 | await device.async_set_position_updated_at(self.media_position_updated_at) 887 | await device.async_set_source(self._source) 888 | await device.async_set_sound_mode(self._sound_mode) 889 | await device.async_set_features(self._features) 890 | 891 | for slave in slave_list['slave_list']: 892 | for device in self.hass.data[DOMAIN].entities: 893 | if device.entity_id in self._multiroom_group: 894 | await device.async_set_multiroom_group(self._multiroom_group) 895 | 896 | else: 897 | _LOGGER.debug("Erroneous JSON during slave list parsing and processing: %s, %s", self.entity_id, self._name) 898 | 899 | return True 900 | 901 | @property 902 | def name(self): 903 | """Return the name of the device.""" 904 | if self._slave_mode: 905 | for dev in self._multiroom_group: 906 | for device in self.hass.data[DOMAIN].entities: 907 | if device._is_master: 908 | return self._name + ' [' + device._name + ']' 909 | else: 910 | return self._name 911 | return self._name 912 | 913 | @property 914 | def icon(self): 915 | """Return the icon of the device.""" 916 | 917 | if self._playing_tts: 918 | return ICON_TTS 919 | 920 | if self._state in [STATE_PAUSED, STATE_UNAVAILABLE, STATE_IDLE, STATE_UNKNOWN]: 921 | return ICON_DEFAULT 922 | 923 | if self._muted: 924 | return ICON_MUTED 925 | 926 | if self._slave_mode or self._is_master: 927 | return ICON_MULTIROOM 928 | 929 | if self._source == "Bluetooth": 930 | return ICON_BLUETOOTH 931 | 932 | if self._source == "DLNA" or self._source == "Airplay" or self._source == "Spotify": 933 | return ICON_PUSHSTREAM 934 | 935 | if self._state == STATE_PLAYING: 936 | return ICON_PLAYING 937 | 938 | return ICON_DEFAULT 939 | 940 | @property 941 | def state(self): 942 | """Return the state of the device.""" 943 | return self._state 944 | 945 | @property 946 | def volume_level(self): 947 | """Volume level of the media player (0..1).""" 948 | return int(self._volume) / MAX_VOL 949 | 950 | @property 951 | def is_volume_muted(self): 952 | """Return boolean if volume is currently muted.""" 953 | return self._muted 954 | 955 | @property 956 | def source(self): 957 | """Return the current input source.""" 958 | if self._source not in ['Idle', 'Network']: 959 | return self._source 960 | else: 961 | return None 962 | 963 | @property 964 | def source_list(self): 965 | """Return the list of available input sources. If only one source exists, don't show it, as it's one and only one - WiFi shouldn't be listed.""" 966 | source_list = self._source_list.copy() 967 | if 'wifi' in source_list: 968 | del source_list['wifi'] 969 | 970 | if len(self._source_list) > 0: 971 | return list(source_list.values()) 972 | else: 973 | return None 974 | 975 | @property 976 | def sound_mode(self): 977 | """Return the current sound mode.""" 978 | return self._sound_mode 979 | 980 | @property 981 | def sound_mode_list(self): 982 | """Return the available sound modes.""" 983 | return sorted(list(SOUND_MODES.values())) 984 | 985 | @property 986 | def supported_features(self) -> MediaPlayerEntityFeature: 987 | """Flag media player features that are supported.""" 988 | if self._slave_mode and self._features: 989 | return self._features 990 | 991 | if self._playing_localfile or self._playing_spotify or self._playing_webplaylist: 992 | if self._state in [STATE_PLAYING, STATE_PAUSED]: 993 | self._features = ( 994 | MediaPlayerEntityFeature.SELECT_SOURCE 995 | | MediaPlayerEntityFeature.SELECT_SOUND_MODE 996 | | MediaPlayerEntityFeature.PLAY_MEDIA 997 | | MediaPlayerEntityFeature.GROUPING 998 | | MediaPlayerEntityFeature.BROWSE_MEDIA 999 | | MediaPlayerEntityFeature.VOLUME_SET 1000 | | MediaPlayerEntityFeature.VOLUME_STEP 1001 | | MediaPlayerEntityFeature.VOLUME_MUTE 1002 | | MediaPlayerEntityFeature.STOP 1003 | | MediaPlayerEntityFeature.PLAY 1004 | | MediaPlayerEntityFeature.PAUSE 1005 | | MediaPlayerEntityFeature.NEXT_TRACK 1006 | | MediaPlayerEntityFeature.PREVIOUS_TRACK 1007 | | MediaPlayerEntityFeature.SHUFFLE_SET 1008 | | MediaPlayerEntityFeature.REPEAT_SET 1009 | | MediaPlayerEntityFeature.SEEK 1010 | ) 1011 | else: 1012 | self._features = ( 1013 | MediaPlayerEntityFeature.SELECT_SOURCE 1014 | | MediaPlayerEntityFeature.SELECT_SOUND_MODE 1015 | | MediaPlayerEntityFeature.PLAY_MEDIA 1016 | | MediaPlayerEntityFeature.GROUPING 1017 | | MediaPlayerEntityFeature.BROWSE_MEDIA 1018 | | MediaPlayerEntityFeature.VOLUME_SET 1019 | | MediaPlayerEntityFeature.VOLUME_STEP 1020 | | MediaPlayerEntityFeature.VOLUME_MUTE 1021 | | MediaPlayerEntityFeature.STOP 1022 | | MediaPlayerEntityFeature.PLAY 1023 | | MediaPlayerEntityFeature.PAUSE 1024 | | MediaPlayerEntityFeature.NEXT_TRACK 1025 | | MediaPlayerEntityFeature.PREVIOUS_TRACK 1026 | | MediaPlayerEntityFeature.SHUFFLE_SET 1027 | | MediaPlayerEntityFeature.REPEAT_SET 1028 | ) 1029 | 1030 | elif self._playing_stream or self._playing_mediabrowser: 1031 | self._features = ( 1032 | MediaPlayerEntityFeature.SELECT_SOURCE 1033 | | MediaPlayerEntityFeature.SELECT_SOUND_MODE 1034 | | MediaPlayerEntityFeature.PLAY_MEDIA 1035 | | MediaPlayerEntityFeature.GROUPING 1036 | | MediaPlayerEntityFeature.BROWSE_MEDIA 1037 | | MediaPlayerEntityFeature.VOLUME_SET 1038 | | MediaPlayerEntityFeature.VOLUME_STEP 1039 | | MediaPlayerEntityFeature.VOLUME_MUTE 1040 | | MediaPlayerEntityFeature.STOP 1041 | | MediaPlayerEntityFeature.PLAY 1042 | | MediaPlayerEntityFeature.PAUSE 1043 | | MediaPlayerEntityFeature.SEEK 1044 | ) 1045 | 1046 | elif self._playing_liveinput: 1047 | self._features = ( 1048 | MediaPlayerEntityFeature.SELECT_SOURCE 1049 | | MediaPlayerEntityFeature.SELECT_SOUND_MODE 1050 | | MediaPlayerEntityFeature.PLAY_MEDIA 1051 | | MediaPlayerEntityFeature.GROUPING 1052 | | MediaPlayerEntityFeature.BROWSE_MEDIA 1053 | | MediaPlayerEntityFeature.VOLUME_SET 1054 | | MediaPlayerEntityFeature.VOLUME_STEP 1055 | | MediaPlayerEntityFeature.VOLUME_MUTE 1056 | | MediaPlayerEntityFeature.STOP 1057 | ) 1058 | 1059 | return self._features 1060 | 1061 | @property 1062 | def media_position(self): 1063 | """Time in seconds of current playback head position.""" 1064 | if (self._playing_localfile or self._playing_spotify or self._slave_mode or self._playing_mediabrowser) and self._state != STATE_UNAVAILABLE: 1065 | return self._playhead_position 1066 | else: 1067 | return None 1068 | 1069 | @property 1070 | def media_duration(self): 1071 | """Time in seconds of current song duration.""" 1072 | if (self._playing_localfile or self._playing_spotify or self._slave_mode or self._playing_mediabrowser) and self._state != STATE_UNAVAILABLE: 1073 | return self._duration 1074 | else: 1075 | return None 1076 | 1077 | @property 1078 | def media_position_updated_at(self): 1079 | """When the seek position was last updated.""" 1080 | if not self._playing_liveinput and self._state == STATE_PLAYING: 1081 | return self._position_updated_at 1082 | else: 1083 | return None 1084 | 1085 | @property 1086 | def shuffle(self): 1087 | """Return True if shuffle mode is enabled.""" 1088 | return self._shuffle 1089 | 1090 | @property 1091 | def repeat(self): 1092 | """Return repeat mode.""" 1093 | return self._repeat 1094 | 1095 | @property 1096 | def media_title(self): 1097 | """Return title of the current track.""" 1098 | return self._media_title 1099 | 1100 | @property 1101 | def media_artist(self): 1102 | """Return name of the current track artist.""" 1103 | return self._media_artist 1104 | 1105 | @property 1106 | def media_album_name(self): 1107 | """Return name of the current track album.""" 1108 | return self._media_album 1109 | 1110 | @property 1111 | def media_image_url(self): 1112 | """Return name the image for the current track.""" 1113 | return self._media_image_url 1114 | 1115 | @property 1116 | def media_content_type(self): 1117 | """Content type of current playing media. Has to be MEDIA_TYPE_MUSIC in order for Lovelace to show both artist and title.""" 1118 | return MEDIA_TYPE_MUSIC 1119 | 1120 | @property 1121 | def ssid(self): 1122 | """SSID to use for multiroom configuration.""" 1123 | return self._ssid 1124 | 1125 | @property 1126 | def wifi_channel(self): 1127 | """Wifi channel to use for multiroom configuration.""" 1128 | return self._wifi_channel 1129 | 1130 | @property 1131 | def slave_ip(self): 1132 | """Ip used in multiroom configuration.""" 1133 | return self._slave_ip 1134 | 1135 | @property 1136 | def slave(self): 1137 | """Return true if it is a slave.""" 1138 | return self._slave_mode 1139 | 1140 | @property 1141 | def master(self): 1142 | """master's entity id used in multiroom configuration.""" 1143 | return self._master 1144 | 1145 | @property 1146 | def is_master(self): 1147 | """Return true if it is a master.""" 1148 | return self._is_master 1149 | 1150 | @property 1151 | def device_class(self) -> MediaPlayerDeviceClass: 1152 | return MediaPlayerDeviceClass.SPEAKER 1153 | 1154 | @property 1155 | def extra_state_attributes(self): 1156 | """List members in group and set master and slave state.""" 1157 | attributes = {} 1158 | if self._multiroom_group != []: 1159 | attributes[ATTR_LINKPLAY_GROUP] = self._multiroom_group 1160 | attributes[ATTR_GROUP_MEMBERS] = self._multiroom_group 1161 | 1162 | attributes[ATTR_MASTER] = self._is_master 1163 | if self._slave_mode: 1164 | attributes[ATTR_SLAVE] = self._slave_mode 1165 | if self._media_uri_final: 1166 | attributes[ATTR_STURI] = self._media_uri_final 1167 | if len(self._trackq) > 0: 1168 | attributes[ATTR_TRCNT] = len(self._trackq) - 1 1169 | if self._trackc: 1170 | attributes[ATTR_TRCRT] = self._trackc 1171 | if self._uuid != '': 1172 | attributes[ATTR_UUID] = self._uuid 1173 | 1174 | attributes[ATTR_TTS] = self._playing_tts 1175 | attributes[ATTR_SNAPSHOT] = self._snapshot_active 1176 | attributes[ATTR_SNAPSPOT] = self._snap_spotify 1177 | 1178 | if DEBUGSTR_ATTR: 1179 | atrdbg = "" 1180 | if self._playing_localfile: 1181 | atrdbg = atrdbg + " _playing_localfile" 1182 | 1183 | if self._playing_spotify: 1184 | atrdbg = atrdbg + " _playing_spotify" 1185 | 1186 | if self._playing_webplaylist: 1187 | atrdbg = atrdbg + " _playing_webplaylist" 1188 | 1189 | if self._playing_stream: 1190 | atrdbg = atrdbg + " _playing_stream" 1191 | 1192 | if self._playing_liveinput: 1193 | atrdbg = atrdbg + " _playing_liveinput" 1194 | 1195 | if self._playing_tts: 1196 | atrdbg = atrdbg + " _playing_tts" 1197 | 1198 | if self._playing_mediabrowser: 1199 | atrdbg = atrdbg + " _playing_mediabrowser" 1200 | 1201 | attributes[ATTR_DEBUG] = atrdbg 1202 | 1203 | if self._state != STATE_UNAVAILABLE: 1204 | attributes[ATTR_FWVER] = self._fw_ver + "." + self._mcu_ver 1205 | 1206 | return attributes 1207 | 1208 | @property 1209 | def host(self): 1210 | """Self ip.""" 1211 | return self._host 1212 | 1213 | @property 1214 | def track_count(self): 1215 | """List of tracks present on the device.""" 1216 | if len(self._trackq) > 0: 1217 | return len(self._trackq) - 1 1218 | else: 1219 | return 0 1220 | 1221 | @property 1222 | def unique_id(self): 1223 | """Return the unique id.""" 1224 | if self._uuid != '': 1225 | return "linkplay_media_" + self._uuid 1226 | 1227 | @property 1228 | def fw_ver(self): 1229 | """Return the firmware version number of the device.""" 1230 | return self._fw_ver 1231 | 1232 | async def async_media_next_track(self): 1233 | """Send media_next command to media player.""" 1234 | if not self._slave_mode: 1235 | value = await self.call_linkplay_httpapi("setPlayerCmd:next", None) 1236 | self._playhead_position = 0 1237 | self._duration = 0 1238 | self._position_updated_at = utcnow() 1239 | self._trackc = None 1240 | self._wait_for_mcu = 2 1241 | if value != "OK": 1242 | _LOGGER.warning("Failed skip to next track. Device: %s, Got response: %s", self.entity_id, value) 1243 | else: 1244 | await self._master.async_media_next_track() 1245 | 1246 | async def async_media_previous_track(self): 1247 | """Send media_previous command to media player.""" 1248 | if not self._slave_mode: 1249 | value = await self.call_linkplay_httpapi("setPlayerCmd:prev", None) 1250 | self._playhead_position = 0 1251 | self._duration = 0 1252 | self._position_updated_at = utcnow() 1253 | self._trackc = None 1254 | self._wait_for_mcu = 2 1255 | if value != "OK": 1256 | _LOGGER.warning("Failed to skip to previous track." " Device: %s, Got response: %s", self.entity_id, value) 1257 | else: 1258 | await self._master.async_media_previous_track() 1259 | 1260 | async def async_media_play(self): 1261 | """Send media_play command to media player.""" 1262 | if not self._slave_mode: 1263 | if self._state == STATE_PAUSED: 1264 | value = await self.call_linkplay_httpapi("setPlayerCmd:resume", None) 1265 | 1266 | elif self._prev_source != None: 1267 | temp_source = next((k for k in self._source_list if self._source_list[k] == self._prev_source), None) 1268 | if temp_source == None: 1269 | return 1270 | 1271 | if temp_source.startswith('http') or temp_source == 'udisk' or temp_source == 'TFcard': 1272 | self.select_source(self._prev_source) 1273 | if self._source != None: 1274 | self._source = None 1275 | value = "OK" 1276 | else: 1277 | value = await self.call_linkplay_httpapi("setPlayerCmd:play", None) 1278 | else: 1279 | value = await self.call_linkplay_httpapi("setPlayerCmd:play", None) 1280 | 1281 | if value == "OK": 1282 | self._state = STATE_PLAYING 1283 | self._unav_throttle = False 1284 | #self._playing_tts = False 1285 | self._position_updated_at = utcnow() 1286 | self._idletime_updated_at = self._position_updated_at 1287 | if self._slave_list is not None: 1288 | for slave in self._slave_list: 1289 | await slave.async_set_state(self._state) 1290 | await slave.async_set_position_updated_at(self.media_position_updated_at) 1291 | else: 1292 | _LOGGER.warning("Failed to start or resume playback. Device: %s, Got response: %s", self.entity_id, value) 1293 | else: 1294 | await self._master.async_media_play() 1295 | 1296 | async def async_media_pause(self): 1297 | """Send media_pause command to media player.""" 1298 | if not self._slave_mode: 1299 | if self._playing_stream and not self._playing_mediabrowser: 1300 | # Pausing a live stream will cause a buffer overrun in hardware. Stop is the correct procedure in this case. 1301 | # If the stream is configured as an input source, when pressing Play after this, it will be started again (using self._prev_source). 1302 | await self.async_media_stop() 1303 | return 1304 | 1305 | value = await self.call_linkplay_httpapi("setPlayerCmd:pause", None) 1306 | if value == "OK": 1307 | self._position_updated_at = utcnow() 1308 | self._idletime_updated_at = self._position_updated_at 1309 | if self._playing_spotify: 1310 | self._spotify_paused_at = utcnow() 1311 | self._state = STATE_PAUSED 1312 | if self._slave_list is not None: 1313 | for slave in self._slave_list: 1314 | await slave.async_set_state(self._state) 1315 | await slave.async_set_position_updated_at(self.media_position_updated_at) 1316 | # #await self.async_schedule_update_ha_state(True) 1317 | else: 1318 | _LOGGER.warning("Failed to pause playback. Device: %s, Got response: %s", self.entity_id, value) 1319 | else: 1320 | await self._master.async_media_pause() 1321 | 1322 | async def async_media_stop(self): 1323 | """Send stop command.""" 1324 | if not self._slave_mode: 1325 | 1326 | if self._playing_spotify or self._playing_liveinput: 1327 | if self._fwvercheck(self._fw_ver) >= self._fwvercheck(FW_SLOW_STREAMS): 1328 | await self.call_linkplay_httpapi("setPlayerCmd:pause", None) 1329 | 1330 | await self.call_linkplay_httpapi("setPlayerCmd:switchmode:wifi", None) 1331 | # self._wait_for_mcu = 1.2 1332 | 1333 | if self._playing_stream: #recent firmwares don't stop the previous stream quickly enough 1334 | if self._fwvercheck(self._fw_ver) >= self._fwvercheck(FW_SLOW_STREAMS): 1335 | await self.call_linkplay_httpapi("setPlayerCmd:pause", None) 1336 | await self.call_linkplay_httpapi("setPlayerCmd:switchmode:wifi", None) 1337 | 1338 | value = await self.call_linkplay_httpapi("setPlayerCmd:stop", None) 1339 | if value == "OK": 1340 | self._state = STATE_IDLE 1341 | self._playhead_position = 0 1342 | self._duration = 0 1343 | self._media_title = None 1344 | self._prev_source = self._source 1345 | self._source = None 1346 | self._nometa = False 1347 | self._media_artist = None 1348 | self._media_album = None 1349 | self._icecast_name = None 1350 | self._media_uri = None 1351 | self._media_uri_final = None 1352 | self._media_source_uri = None 1353 | self._playing_mediabrowser = False 1354 | self._playing_stream = False 1355 | self._trackc = None 1356 | self._media_image_url = None 1357 | self._position_updated_at = utcnow() 1358 | self._idletime_updated_at = self._position_updated_at 1359 | self._spotify_paused_at = None 1360 | #await self.async_schedule_update_ha_state(True) 1361 | if self._slave_list is not None: 1362 | for slave in self._slave_list: 1363 | await slave.async_set_state(self._state) 1364 | await slave.async_set_position_updated_at(self.media_position_updated_at) 1365 | else: 1366 | _LOGGER.warning("Failed to stop playback. Device: %s, Got response: %s", self.entity_id, value) 1367 | else: 1368 | await self._master.async_media_stop() 1369 | 1370 | async def async_media_seek(self, position): 1371 | """Send media_seek command to media player.""" 1372 | if not self._slave_mode: 1373 | _LOGGER.debug("Seek. Device: %s, DUR: %s POS: %", self.name, self._duration, position) 1374 | if self._duration > 0 and position >= 0 and position <= self._duration: 1375 | value = await self.call_linkplay_httpapi("setPlayerCmd:seek:{0}".format(str(position)), None) 1376 | self._position_updated_at = utcnow() 1377 | self._idletime_updated_at = self._position_updated_at 1378 | self._wait_for_mcu = 0.2 1379 | if value != "OK": 1380 | _LOGGER.warning("Failed to seek. Device: %s, Got response: %s", self.entity_id, value) 1381 | else: 1382 | await self._master.async_media_seek(position) 1383 | 1384 | async def async_clear_playlist(self): 1385 | """Clear players playlist.""" 1386 | pass 1387 | 1388 | async def async_play_media(self, media_type, media_id, **kwargs): 1389 | """Play media from a URL or localfile.""" 1390 | _LOGGER.debug("Trying to play media. Device: %s, Media_type: %s, Media_id: %s", self.entity_id, media_type, media_id) 1391 | if not self._slave_mode: 1392 | 1393 | if not (media_type in [MEDIA_TYPE_MUSIC, MEDIA_TYPE_URL, MEDIA_TYPE_TRACK] or media_source.is_media_source_id(media_id)): 1394 | _LOGGER.warning("For: %s Invalid media type %s. Only %s and %s is supported", self._name, media_type, MEDIA_TYPE_MUSIC, MEDIA_TYPE_URL) 1395 | await self.async_media_stop() 1396 | return False 1397 | 1398 | if not self._snapshot_active: 1399 | self._playing_mediabrowser = False 1400 | self._nometa = False 1401 | 1402 | if media_source.is_media_source_id(media_id): 1403 | play_item = await media_source.async_resolve_media(self.hass, media_id, self.entity_id) 1404 | if media_id.find('radio_browser') != -1: # radios are an exception, be treated by server redirect checker and icecast metadata parser 1405 | self._playing_mediabrowser = False 1406 | else: 1407 | self._playing_mediabrowser = True 1408 | 1409 | if media_id.find('media_source/local') != -1: 1410 | self._media_source_uri = media_id 1411 | else: 1412 | self._media_source_uri = None 1413 | 1414 | media_id = play_item.url 1415 | if not play_item.mime_type in ['audio/basic', 1416 | 'audio/mpeg', 1417 | 'audio/mp3', 1418 | 'audio/mpeg3', 1419 | 'audio/x-mpeg-3', 1420 | 'audio/x-mpegurl', 1421 | 'audio/mp4', 1422 | 'audio/aac', 1423 | 'audio/x-aac', 1424 | 'audio/x-hx-aac-adts', 1425 | 'audio/x-aiff', 1426 | 'audio/ogg', 1427 | 'audio/vorbis', 1428 | 'application/ogg', 1429 | 'audio/opus', 1430 | 'audio/webm', 1431 | 'audio/wav', 1432 | 'audio/x-wav', 1433 | 'audio/vnd.wav', 1434 | 'audio/flac', 1435 | 'audio/x-flac', 1436 | 'audio/x-ms-wma']: 1437 | _LOGGER.warning("For: %s Invalid media type, %s is not supported", self._name, play_item.mime_type) 1438 | self._playing_mediabrowser = False 1439 | return False 1440 | 1441 | media_id = async_process_play_media_url(self.hass, media_id) 1442 | _LOGGER.debug("Trying to play HA media. Device: %s, Play_Item: %s, Media_id: %s", self._name, play_item, media_id) 1443 | 1444 | media_id_check = media_id.lower() 1445 | 1446 | if media_id_check.startswith('http'): 1447 | media_type = MEDIA_TYPE_URL 1448 | 1449 | if media_id_check.endswith('.m3u') or media_id_check.endswith('.m3u8'): 1450 | _LOGGER.debug("For: %s, Detected M3U list: %s, Media_id: %s", self._name, media_id) 1451 | media_id = await self.async_parse_m3u_url(media_id) 1452 | 1453 | if media_id_check.endswith('.pls'): 1454 | _LOGGER.debug("For: %s, Detected PLS list: %s, Media_id: %s", self._name, media_id) 1455 | media_id = await self.async_parse_pls_url(media_id) 1456 | 1457 | if media_type == MEDIA_TYPE_URL: 1458 | if self._playing_mediabrowser: 1459 | media_id_final = media_id 1460 | else: 1461 | media_id_final = await self.async_detect_stream_url_redirection(media_id) 1462 | 1463 | if self._fwvercheck(self._fw_ver) >= self._fwvercheck(FW_SLOW_STREAMS) and self._state == STATE_PLAYING: 1464 | await self.call_linkplay_httpapi("setPlayerCmd:pause", None) 1465 | 1466 | if self._playing_spotify: # disconnect from Spotify before playing new http source 1467 | await self.call_linkplay_httpapi("setPlayerCmd:switchmode:wifi", None) 1468 | 1469 | value = await self.call_linkplay_httpapi("setPlayerCmd:play:{0}".format(media_id_final), None) 1470 | if value != "OK": 1471 | _LOGGER.warning("Failed to play media type URL. Device: %s, Got response: %s, Media_Id: %s", self.entity_id, value, media_id) 1472 | return False 1473 | 1474 | elif media_type in [MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK]: 1475 | value = await self.call_linkplay_httpapi("setPlayerCmd:playLocalList:{0}".format(media_id), None) 1476 | if value != "OK": 1477 | _LOGGER.warning("Failed to play media type music. Device: %s, Got response: %s, Media_Id: %s", self.entity_id, value, media_id) 1478 | return False 1479 | 1480 | self._state = STATE_PLAYING 1481 | if media_id.find('tts_proxy') != -1: 1482 | #_LOGGER.debug("Setting TTS: %s, %s", self.entity_id, self._name) 1483 | self._playing_tts = True 1484 | self._playing_mediabrowser = False 1485 | self._playing_stream = False 1486 | else: 1487 | self._playing_tts = False 1488 | self._media_title = None 1489 | self._media_artist = None 1490 | self._media_album = None 1491 | self._icecast_name = None 1492 | self._playhead_position = 0 1493 | self._duration = 0 1494 | self._trackc = None 1495 | self._position_updated_at = utcnow() 1496 | self._idletime_updated_at = self._position_updated_at 1497 | self._media_image_url = None 1498 | self._ice_skip_throt = True 1499 | self._unav_throttle = False 1500 | if media_type == MEDIA_TYPE_URL: 1501 | self._media_uri = media_id 1502 | self._media_uri_final = media_id_final 1503 | elif media_type == MEDIA_TYPE_MUSIC: 1504 | self._media_uri = None 1505 | self._media_uri_final = None 1506 | self._wait_for_mcu = 0.4 1507 | return True 1508 | 1509 | else: 1510 | if not self._snapshot_active: 1511 | await self._master.async_play_media(media_type, media_id) 1512 | 1513 | async def async_select_source(self, source): 1514 | """Select input source.""" 1515 | if not self._slave_mode: 1516 | self._nometa = False 1517 | temp_source = next((k for k in self._source_list if self._source_list[k] == source), None) 1518 | if temp_source == None: 1519 | return 1520 | 1521 | if self._playing_spotify: # disconnect from Spotify before selecting new source 1522 | if self._fwvercheck(self._fw_ver) >= self._fwvercheck(FW_SLOW_STREAMS): 1523 | await self.call_linkplay_httpapi("setPlayerCmd:pause", None) 1524 | await self.call_linkplay_httpapi("setPlayerCmd:switchmode:wifi", None) 1525 | 1526 | if temp_source == "udisk": 1527 | await self.async_tracklist_via_upnp("USB") 1528 | 1529 | if len(self._source_list) > 0: 1530 | prev_source = next((k for k in self._source_list if self._source_list[k] == self._source), None) 1531 | 1532 | if prev_source and prev_source.startswith('http') and temp_source in ['line-in', 'line-in2', 'optical', 'bluetooth', 'co-axial', 'HDMI', 'cd', 'udisk', 'RCA']: 1533 | self._wait_for_mcu = 1 1534 | 1535 | self._unav_throttle = False 1536 | if temp_source.startswith('http'): 1537 | temp_source_final = await self.async_detect_stream_url_redirection(temp_source) 1538 | 1539 | if self._fwvercheck(self._fw_ver) >= self._fwvercheck(FW_SLOW_STREAMS) and self._state == STATE_PLAYING: 1540 | await self.call_linkplay_httpapi("setPlayerCmd:pause", None) #recent firmwares don't stop the previous stream while loading the new one, can take several seconds 1541 | 1542 | value = await self.call_linkplay_httpapi("setPlayerCmd:play:{0}".format(temp_source_final), None) 1543 | if value == "OK": 1544 | self._state = STATE_PLAYING 1545 | if prev_source and prev_source.find('http') == -1: 1546 | self._wait_for_mcu = 2 # switching from live to stream input -> time to report correct volume value at update 1547 | else: 1548 | self._wait_for_mcu = 0.5 1549 | self._playing_tts = False 1550 | self._source = source 1551 | self._media_uri = temp_source 1552 | self._media_uri_final = temp_source_final 1553 | self._playhead_position = 0 1554 | self._duration = 0 1555 | self._trackc = None 1556 | self._position_updated_at = utcnow() 1557 | self._idletime_updated_at = self._position_updated_at 1558 | self._media_title = None 1559 | self._media_artist = None 1560 | self._media_album = None 1561 | self._icecast_name = None 1562 | self._media_image_url = None 1563 | self._ice_skip_throt = True 1564 | if self._slave_list is not None: 1565 | for slave in self._slave_list: 1566 | await slave.async_set_source(source) 1567 | else: 1568 | _LOGGER.warning("Failed to select http source and play. Device: %s, Got response: %s", self.entity_id, value) 1569 | else: 1570 | value = await self.call_linkplay_httpapi("setPlayerCmd:switchmode:{0}".format(temp_source), None) 1571 | if value == "OK": 1572 | self._state = STATE_PLAYING 1573 | # if temp_source and temp_source in ['udisk', 'TFcard']: 1574 | # self._wait_for_mcu = 2 # switching to locally stored files -> time to report correct volume value at update 1575 | # else: 1576 | # self._wait_for_mcu = 0.6 # switching to a physical input -> time to report correct volume value at update 1577 | self._source = source 1578 | self._media_uri = None 1579 | self._media_uri_final = None 1580 | self._playhead_position = 0 1581 | self._duration = 0 1582 | self._trackc = None 1583 | self._position_updated_at = utcnow() 1584 | self._idletime_updated_at = self._position_updated_at 1585 | if self._slave_list is not None: 1586 | for slave in self._slave_list: 1587 | await slave.async_set_source(source) 1588 | else: 1589 | _LOGGER.warning("Failed to select source. Device: %s, Got response: %s", self.entity_id, value) 1590 | 1591 | #await self.async_schedule_update_ha_state(True) 1592 | else: 1593 | await self._master.async_select_source(source) 1594 | 1595 | async def async_select_sound_mode(self, sound_mode): 1596 | """Set Sound Mode for device.""" 1597 | if not self._slave_mode: 1598 | mode = list(SOUND_MODES.keys())[list( 1599 | SOUND_MODES.values()).index(sound_mode)] 1600 | value = await self.call_linkplay_httpapi("setPlayerCmd:equalizer:{0}".format(mode), None) 1601 | if value == "OK": 1602 | self._sound_mode = sound_mode 1603 | if self._slave_list is not None: 1604 | for slave in self._slave_list: 1605 | await slave.async_set_sound_mode(sound_mode) 1606 | else: 1607 | _LOGGER.warning("Failed to set sound mode. Device: %s, Got response: %s", self.entity_id, value) 1608 | else: 1609 | await self._master.async_select_sound_mode(sound_mode) 1610 | 1611 | async def async_set_shuffle(self, shuffle): 1612 | """Change the shuffle mode.""" 1613 | if not self._slave_mode: 1614 | if shuffle: 1615 | self._shuffle = shuffle 1616 | mode = '2' 1617 | else: 1618 | if self._repeat == REPEAT_MODE_OFF: 1619 | mode = '0' 1620 | elif self._repeat == REPEAT_MODE_ALL: 1621 | mode = '3' 1622 | elif self._repeat == REPEAT_MODE_ONE: 1623 | mode = '1' 1624 | value = await self.call_linkplay_httpapi("setPlayerCmd:loopmode:{0}".format(mode), None) 1625 | if value != "OK": 1626 | _LOGGER.warning("Failed to change shuffle mode. Device: %s, Got response: %s", self.entity_id, value) 1627 | else: 1628 | await self._master.async_set_shuffle(shuffle) 1629 | 1630 | async def async_set_repeat(self, repeat): 1631 | """Change the repeat mode.""" 1632 | if not self._slave_mode: 1633 | self._repeat = repeat 1634 | if repeat == REPEAT_MODE_OFF: 1635 | mode = '0' 1636 | elif repeat == REPEAT_MODE_ALL: 1637 | mode = '2' if self._shuffle else '3' 1638 | elif repeat == REPEAT_MODE_ONE: 1639 | mode = '1' 1640 | value = await self.call_linkplay_httpapi("setPlayerCmd:loopmode:{0}".format(mode), None) 1641 | if value != "OK": 1642 | _LOGGER.warning("Failed to change repeat mode. Device: %s, Got response: %s", self.entity_id, value) 1643 | else: 1644 | await self._master.async_set_repeat(repeat) 1645 | 1646 | async def async_volume_up(self): 1647 | """Increase volume one step""" 1648 | if int(self._volume) == 100 and not self._muted: 1649 | return 1650 | 1651 | volume = int(self._volume) + int(self._volume_step) 1652 | if volume > 100: 1653 | volume = 100 1654 | 1655 | if not (self._slave_mode and self._multiroom_wifidirect): 1656 | 1657 | if self._is_master: 1658 | value = await self.call_linkplay_httpapi("setPlayerCmd:slave_vol:{0}".format(str(volume)), None) 1659 | else: 1660 | value = await self.call_linkplay_httpapi("setPlayerCmd:vol:{0}".format(str(volume)), None) 1661 | 1662 | if value == "OK": 1663 | self._volume = volume 1664 | else: 1665 | _LOGGER.warning("Failed to set volume_up. Device: %s, Got response: %s", self.entity_id, value) 1666 | else: 1667 | if self._snapshot_active: 1668 | return 1669 | value = await self._master.call_linkplay_httpapi("multiroom:SlaveVolume:{0}:{1}".format(self._slave_ip, str(volume)), None) 1670 | if value == "OK": 1671 | self._volume = volume 1672 | else: 1673 | _LOGGER.warning("Failed to set volume_up. Device: %s, Got response: %s", self.entity_id, value) 1674 | 1675 | async def async_volume_down(self): 1676 | """Decrease volume one step.""" 1677 | if int(self._volume) == 0: 1678 | return 1679 | 1680 | volume = int(self._volume) - int(self._volume_step) 1681 | if volume < 0: 1682 | volume = 0 1683 | 1684 | if not (self._slave_mode and self._multiroom_wifidirect): 1685 | 1686 | if self._is_master: 1687 | value = await self.call_linkplay_httpapi("setPlayerCmd:slave_vol:{0}".format(str(volume)), None) 1688 | else: 1689 | value = await self.call_linkplay_httpapi("setPlayerCmd:vol:{0}".format(str(volume)), None) 1690 | 1691 | if value == "OK": 1692 | self._volume = volume 1693 | else: 1694 | _LOGGER.warning("Failed to set volume_down. Device: %s, Got response: %s", self.entity_id, value) 1695 | else: 1696 | if self._snapshot_active: 1697 | return 1698 | value = await self._master.call_linkplay_httpapi("multiroom:SlaveVolume:{0}:{1}".format(self._slave_ip, str(volume)), None) 1699 | if value == "OK": 1700 | self._volume = volume 1701 | else: 1702 | _LOGGER.warning("Failed to set volume_down. Device: %s, Got response: %s", self.entity_id, value) 1703 | 1704 | async def async_set_volume_level(self, volume): 1705 | """Set volume level, input range 0..1, linkplay device 0..100.""" 1706 | volume = str(round(int(volume * MAX_VOL))) 1707 | if not (self._slave_mode and self._multiroom_wifidirect): 1708 | 1709 | # if self._fadevol: 1710 | # voldiff = int(self._volume) - int(volume) 1711 | # steps = 1 1712 | # if voldiff < 33: 1713 | # steps = 2 1714 | # elif voldiff >= 33 and voldiff < 66: 1715 | # steps = 4 1716 | # elif voldiff > 66: 1717 | # steps = 6 1718 | # volstep = int(round(voldiff / steps)) 1719 | # voltemp = int(self._volume) 1720 | # # self._wait_for_mcu = 1 # set delay in update routine for the fade to finish 1721 | # for v in (range(0, steps - 1)): 1722 | # voltemp = voltemp - volstep 1723 | # # self._lpapi.call('GET', 'setPlayerCmd:vol:{0}'.format(str(voltemp))) 1724 | # value = await self.call_linkplay_httpapi("setPlayerCmd:vol:{0}".format(str(voltemp)), None) 1725 | # await asyncio.sleep(0.6 / steps) 1726 | 1727 | if self._is_master: 1728 | value = await self.call_linkplay_httpapi("setPlayerCmd:slave_vol:{0}".format(str(volume)), None) 1729 | else: 1730 | if self._snapshot_active: 1731 | await asyncio.sleep(1) 1732 | value = await self.call_linkplay_httpapi("setPlayerCmd:vol:{0}".format(str(volume)), None) 1733 | 1734 | if value == "OK": 1735 | self._volume = volume 1736 | else: 1737 | _LOGGER.warning("Failed to set volume. Device: %s, Got response: %s", self.entity_id, value) 1738 | else: 1739 | if self._snapshot_active: 1740 | return 1741 | value = await self._master.call_linkplay_httpapi("multiroom:SlaveVolume:{0}:{1}".format(self._slave_ip, str(volume)), None) 1742 | if value == "OK": 1743 | self._volume = volume 1744 | else: 1745 | _LOGGER.warning("Failed to set volume. Device: %s, Got response: %s", self.entity_id, value) 1746 | 1747 | 1748 | async def async_mute_volume(self, mute): 1749 | """Mute (true) or unmute (false) media player.""" 1750 | if not (self._slave_mode and self._multiroom_wifidirect): 1751 | if self._is_master: 1752 | value = await self.call_linkplay_httpapi("setPlayerCmd:slave_mute:{0}".format(str(int(mute))), None) 1753 | else: 1754 | value = await self.call_linkplay_httpapi("setPlayerCmd:mute:{0}".format(str(int(mute))), None) 1755 | 1756 | if value == "OK": 1757 | self._muted = bool(int(mute)) 1758 | else: 1759 | _LOGGER.warning("Failed mute/unmute volume. Device: %s, Got response: %s", self.entity_id, value) 1760 | else: 1761 | value = await self._master.call_linkplay_httpapi("multiroom:SlaveVolume:{0}:{1}".format(self._slave_ip, str(int(mute))), None) 1762 | if value == "OK": 1763 | self._muted = bool(int(mute)) 1764 | else: 1765 | _LOGGER.warning("Failed mute/unmute volume. Device: %s, Got response: %s", self.entity_id, value) 1766 | 1767 | async def call_update_lastfm(self, cmd, params): 1768 | """Update LastFM metadata.""" 1769 | url = "{0}{1}&{2}&api_key={3}&format=json".format(LASTFM_API_BASE, cmd, params, self._lastfm_api_key) 1770 | #_LOGGER.debug("Updating LastFM from URL: %s", url) 1771 | 1772 | try: 1773 | websession = async_get_clientsession(self.hass) 1774 | response = await websession.get(url) 1775 | if response.status == HTTPStatus.OK: 1776 | data = await response.json(content_type=None) #response.text() 1777 | 1778 | else: 1779 | _LOGGER.error( 1780 | "Get failed, response code: %s Full message: %s", 1781 | response.status, 1782 | response, 1783 | ) 1784 | return False 1785 | 1786 | except (asyncio.TimeoutError, aiohttp.ClientError) as error: 1787 | _LOGGER.error( 1788 | "Failed communicating with LastFM '%s': %s", self._name, type(error) 1789 | ) 1790 | return False 1791 | return data 1792 | 1793 | @Throttle(LFM_THROTTLE) 1794 | async def async_get_lastfm_coverart(self): 1795 | """Get cover art from last.fm.""" 1796 | if self._media_title is None or self._media_artist is None: 1797 | self._media_image_url = None 1798 | return 1799 | 1800 | coverart_url = None 1801 | lfmdata = await self.call_update_lastfm('track.getInfo', "artist={0}&track={1}".format(self._media_artist, self._media_title)) 1802 | 1803 | try: 1804 | coverart_url = lfmdata['track']['album']['image'][3]['#text'] 1805 | except (ValueError, KeyError): 1806 | coverart_url = None 1807 | 1808 | if coverart_url == '' or coverart_url == None: 1809 | self._media_image_url = None 1810 | else: 1811 | if coverart_url.find('2a96cbd8b46e442fc41c2b86b821562f') != -1: 1812 | # don't show the sheriff star empty cover https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png 1813 | _LOGGER.debug("LastFM ratelimited") 1814 | self._media_image_url = None 1815 | else: 1816 | self._media_image_url = coverart_url 1817 | 1818 | async def async_get_local_mediasource_metadata_from_path(self): 1819 | if self._media_source_uri is not None: 1820 | rootdir = "media-source://media_source/local/" 1821 | self._trackc = self._media_source_uri.replace(rootdir, '') 1822 | titleuri = self._trackc.split('/') 1823 | if len(titleuri) > 1: 1824 | titles = titleuri[-2:] 1825 | self._media_artist = string.capwords(titles[0].strip().strip('-').replace('_', ' ')) 1826 | self._media_title = string.capwords(titles[1].strip().strip('-').replace('_', ' ')) 1827 | else: 1828 | self._media_title = string.capwords(titleuri[0].strip().strip('-').replace('_', ' ')) 1829 | querywords = self._media_title.split('.') 1830 | resultwords = [word for word in querywords if word.lower() not in CUT_EXTENSIONS] 1831 | self._media_title = ' '.join(resultwords) 1832 | return True 1833 | else: 1834 | return False 1835 | 1836 | async def async_get_playerstatus_metadata(self, plr_stat): 1837 | try: 1838 | if plr_stat['uri'] != "": 1839 | rootdir = ROOTDIR_USB 1840 | try: 1841 | self._trackc = str(bytearray.fromhex(plr_stat['uri']).decode('utf-8')).replace(rootdir, '') 1842 | except ValueError: 1843 | self._trackc = plr_stat['uri'].replace(rootdir, '') 1844 | except KeyError: 1845 | pass 1846 | if plr_stat['Title'] != '': 1847 | try: 1848 | title = str(bytearray.fromhex(plr_stat['Title']).decode('utf-8')) 1849 | except ValueError: 1850 | title = plr_stat['Title'] 1851 | if title.lower() != 'unknown': 1852 | self._media_title = string.capwords(title) 1853 | if self._trackc == None: 1854 | self._trackc = self._media_title 1855 | else: 1856 | self._media_title = None 1857 | if plr_stat['Artist'] != '': 1858 | try: 1859 | artist = str(bytearray.fromhex(plr_stat['Artist']).decode('utf-8')) 1860 | except ValueError: 1861 | artist = plr_stat['Artist'] 1862 | if artist.lower() != 'unknown': 1863 | self._media_artist = string.capwords(artist) 1864 | else: 1865 | self._media_artist = None 1866 | if plr_stat['Album'] != '': 1867 | try: 1868 | album = str(bytearray.fromhex(plr_stat['Album']).decode('utf-8')) 1869 | except ValueError: 1870 | album = plr_stat['Album'] 1871 | if album.lower() != 'unknown': 1872 | self._media_album = string.capwords(album) 1873 | else: 1874 | self._media_album = None 1875 | 1876 | if self._media_title is not None and self._media_artist is not None: 1877 | return True 1878 | else: 1879 | return False 1880 | 1881 | @Throttle(ICE_THROTTLE) 1882 | async def async_update_from_icecast(self): 1883 | """Update track info from icecast stream.""" 1884 | if self._icecast_meta == 'Off': 1885 | return True 1886 | 1887 | #_LOGGER.debug('For: %s Looking for IceCast metadata in: %s', self._name, self._media_uri_final) 1888 | 1889 | # def NiceToICY(self): 1890 | # class InterceptedHTTPResponse(): 1891 | # pass 1892 | # import io 1893 | # line = self.fp.readline().replace(b"ICY 200 OK\r\n", b"HTTP/1.0 200 OK\r\n") 1894 | # InterceptedSelf = InterceptedHTTPResponse() 1895 | # InterceptedSelf.fp = io.BufferedReader(io.BytesIO(line)) 1896 | # InterceptedSelf.debuglevel = self.debuglevel 1897 | # InterceptedSelf._close_conn = self._close_conn 1898 | # return ORIGINAL_HTTP_CLIENT_READ_STATUS(InterceptedSelf) 1899 | 1900 | # ORIGINAL_HTTP_CLIENT_READ_STATUS = urllib.request.http.client.HTTPResponse._read_status 1901 | # urllib.request.http.client.HTTPResponse._read_status = NiceToICY 1902 | 1903 | try: 1904 | request = urllib.request.Request(self._media_uri_final, headers={'Icy-MetaData': '1','User-Agent': 'VLC/3.0.16 LibVLC/3.0.16'}) # request metadata 1905 | response = await self.hass.async_add_executor_job(urllib.request.urlopen, request) 1906 | except: # (urllib.error.HTTPError) 1907 | _LOGGER.debug('For: %s Metadata error: %s', self._name, self._media_uri_final) 1908 | self._media_title = None 1909 | self._media_artist = None 1910 | self._icecast_name = None 1911 | self._media_image_url = None 1912 | return True 1913 | 1914 | icy_name = response.headers['icy-name'] 1915 | if icy_name is not None and icy_name != 'no name' and icy_name != 'Unspecified name' and icy_name != '-': 1916 | try: # 'latin1' # default: iso-8859-1 for mp3 and utf-8 for ogg streams 1917 | self._icecast_name = icy_name.encode('latin1').decode('utf-8') 1918 | except (UnicodeDecodeError): 1919 | self._icecast_name = icy_name 1920 | #_LOGGER.debug('For: %s found icy_name: %s', self._name, '"' + icy_name + '"') 1921 | 1922 | else: 1923 | self._icecast_name = None 1924 | 1925 | if self._icecast_meta == 'StationName': 1926 | self._media_title = self._icecast_name 1927 | self._media_artist = None 1928 | self._media_image_url = None 1929 | return True 1930 | 1931 | icy_metaint_header = response.headers['icy-metaint'] 1932 | if icy_metaint_header is not None: 1933 | metaint = int(icy_metaint_header) 1934 | for _ in range(10): # title may be empty initially, try several times 1935 | response.read(metaint) # skip to metadata 1936 | metadata_length = struct.unpack('B', response.read(1))[0] * 16 # length byte 1937 | metadata = response.read(metadata_length).rstrip(b'\0') 1938 | #_LOGGER.debug('For: %s found metadata: %s', self._name, metadata) 1939 | 1940 | # extract title from the metadata 1941 | # m = re.search(br"StreamTitle='([^']*)';", metadata) 1942 | m = re.search(br"StreamTitle='(.*)';", metadata) 1943 | #_LOGGER.debug('For: %s found m: %s', self._name, m) 1944 | if m: 1945 | title = m.group(0) 1946 | #_LOGGER.debug('For: %s found title: %s', self._name, title) 1947 | 1948 | if title: 1949 | code_detect = chardet.detect(title)['encoding'] 1950 | title = title.decode(code_detect, errors='ignore') 1951 | titlek = title.split("';") 1952 | title = titlek[0] 1953 | titlem = title.split("='") 1954 | title = titlem[1] 1955 | #_LOGGER.debug('For: %s found decoded title: %s', self._name, title) 1956 | 1957 | title = re.sub(r'\[.*?\]\ *', '', title) # "\s*\[.*?\]\s*"," ",title) 1958 | if title.find('~~~~~') != -1: # for United Music Subasio servers 1959 | titles = title.split('~') 1960 | self._media_artist = string.capwords(titles[0].strip().strip('-')) 1961 | self._media_title = string.capwords(titles[1].strip().strip('-')) 1962 | elif title.find(' - ') != -1: # for ordinary Icecast servers 1963 | titles = title.split(' - ') 1964 | self._media_artist = string.capwords(titles[0].strip().strip('-')) 1965 | self._media_title = string.capwords(titles[1].strip().strip('-')) 1966 | else: 1967 | if self._icecast_name is not None: 1968 | self._media_artist = '[' + self._icecast_name + ']' 1969 | else: 1970 | self._media_artist = None 1971 | self._media_title = string.capwords(title) 1972 | 1973 | if self._media_artist == '-': 1974 | self._media_artist = None 1975 | if self._media_title == '-': 1976 | self._media_title = None 1977 | 1978 | if self._media_artist is not None: 1979 | self._media_artist.replace('/', ' / ') 1980 | self._media_artist.replace(' ', ' ') 1981 | 1982 | if self._media_title is not None: 1983 | self._media_title.replace('/', ' / ') 1984 | self._media_title.replace(' ', ' ') 1985 | 1986 | break 1987 | else: 1988 | if self._icecast_name is not None: 1989 | self._media_title = self._icecast_name 1990 | else: 1991 | self._media_title = None 1992 | self._media_artist = None 1993 | self._media_image_url = None 1994 | 1995 | else: 1996 | if self._icecast_name is not None: 1997 | self._media_title = self._icecast_name 1998 | else: 1999 | self._media_title = None 2000 | self._media_artist = None 2001 | self._media_image_url = None 2002 | 2003 | #_LOGGER.debug('For: %s stated media_title: %s', self._name, self._media_title) 2004 | #_LOGGER.debug('For: %s stated media_artist: %s', self._name, self._media_artist) 2005 | 2006 | async def async_detect_stream_url_redirection(self, uri): 2007 | if uri.find('tts_proxy') != -1: # skip redirect check for local TTS streams 2008 | return uri 2009 | _LOGGER.debug('For: %s detect URI redirect-from: %s', self._name, uri) 2010 | redirect_detect = True 2011 | check_uri = uri 2012 | try: 2013 | while redirect_detect: 2014 | headsession = async_get_clientsession(self.hass) 2015 | response_location = await headsession.head(check_uri, allow_redirects=False, headers={'User-Agent': 'VLC/3.0.16 LibVLC/3.0.16'}) 2016 | _LOGGER.debug('For: %s detecting URI redirect code: %s', self._name, str(response_location.status)) 2017 | if response_location.status in [301, 302, 303, 307, 308] and 'Location' in response_location.headers: 2018 | _LOGGER.debug('For: %s detecting URI redirect location: %s', self._name, response_location.headers['Location']) 2019 | check_uri = response_location.headers['Location'] 2020 | else: 2021 | _LOGGER.debug('For: %s detecting URI redirect - result: %s', self._name, check_uri) 2022 | redirect_detect = False 2023 | except Exception as error: 2024 | _LOGGER.debug("Exception:" + str(error)) 2025 | 2026 | _LOGGER.debug('For: %s detect URI redirect - to: %s', self._name, check_uri) 2027 | return check_uri 2028 | 2029 | async def async_parse_m3u_url(self, playlist): 2030 | """Parse an M3U playlist URL for actual streams, and return the first one""" 2031 | try: 2032 | websession = async_get_clientsession(self.hass) 2033 | async with async_timeout.timeout(10): 2034 | response = await websession.get(playlist) 2035 | 2036 | except (asyncio.TimeoutError, aiohttp.ClientError) as error: 2037 | _LOGGER.warning( 2038 | "For: %s unable to get the M3U playlist: %s", self._name, playlist 2039 | ) 2040 | return playlist 2041 | 2042 | if response.status == HTTPStatus.OK: 2043 | data = await response.text() 2044 | _LOGGER.debug("For: %s M3U playlist: %s contents: %s", self._name, playlist, data) 2045 | 2046 | lines = [line.strip("\n\r") for line in data.split("\n") if line.strip("\n\r") != ""] 2047 | if len(lines) > 0: 2048 | _LOGGER.debug("For: %s M3U playlist: %s lines: %s", self._name, playlist, lines) 2049 | urls = [u for u in lines if u.startswith('http')] 2050 | _LOGGER.debug("For: %s M3U playlist: %s urls: %s", self._name, playlist, urls) 2051 | if len(urls) > 0: 2052 | return urls[0] 2053 | else: 2054 | _LOGGER.error("For: %s M3U playlist: %s No valid http URL in the playlist!!!", self._name, playlist) 2055 | self._nometa = True 2056 | else: 2057 | _LOGGER.error("For: %s M3U playlist: %s No content to parse!!!", self._name, playlist) 2058 | 2059 | else: 2060 | _LOGGER.error( 2061 | "For: %s (%s) Get failed, response code: %s Full message: %s", 2062 | self._name, 2063 | self._host, 2064 | response.status, 2065 | response, 2066 | ) 2067 | 2068 | return playlist 2069 | 2070 | async def async_parse_pls_url(self, playlist): 2071 | """Parse a PLS playlist URL for actual streams, and return the first one""" 2072 | try: 2073 | websession = async_get_clientsession(self.hass) 2074 | async with async_timeout.timeout(10): 2075 | response = await websession.get(playlist) 2076 | 2077 | except (asyncio.TimeoutError, aiohttp.ClientError) as error: 2078 | _LOGGER.warning( 2079 | "For: %s unable to get the PLS playlist: %s", self._name, playlist 2080 | ) 2081 | return playlist 2082 | 2083 | if response.status == HTTPStatus.OK: 2084 | data = await response.text() 2085 | _LOGGER.debug("For: %s PLS playlist: %s contents: %s", self._name, playlist, data) 2086 | 2087 | lines = [line.strip("\n\r") for line in data.split("\n") if line.strip("\n\r") != ""] 2088 | if len(lines) > 0: 2089 | _LOGGER.debug("For: %s PLS playlist: %s lines: %s", self._name, playlist, lines) 2090 | urls = [u for u in lines if u.startswith('File')] 2091 | _LOGGER.debug("For: %s PLS playlist: %s urls: %s", self._name, playlist, urls) 2092 | if len(urls) > 0: 2093 | url = urls[0].split('=') 2094 | if len(url) > 1: 2095 | return url[1] 2096 | else: 2097 | _LOGGER.error("For: %s PLS playlist: %s No valid http URL in the playlist!!!", self._name, playlist) 2098 | self._nometa = True 2099 | else: 2100 | _LOGGER.error("For: %s PLS playlist: %s No content to parse!!!", self._name, playlist) 2101 | 2102 | else: 2103 | _LOGGER.error( 2104 | "For: %s (%s) Get failed, response code: %s Full message: %s", 2105 | self._name, 2106 | self._host, 2107 | response.status, 2108 | response, 2109 | ) 2110 | 2111 | return playlist 2112 | 2113 | def _fwvercheck(self, v): 2114 | filled = [] 2115 | for point in v.split("."): 2116 | filled.append(point.zfill(8)) 2117 | return tuple(filled) 2118 | 2119 | async def async_is_playing_new_track(self): 2120 | """Check if track is changed since last update.""" 2121 | if self._playing_mediabrowser and self._media_source_uri is not None: 2122 | # don't trigger new track flag for local mediabrowser files 2123 | return False 2124 | 2125 | if self._icecast_name != None: 2126 | import unicodedata 2127 | artmed = unicodedata.normalize('NFKD', str(self._media_artist) + str(self._media_title)).lower() 2128 | artmedd = u"".join([c for c in artmed if not unicodedata.combining(c)]) 2129 | if artmedd.find(self._icecast_name.lower()) != -1 or artmedd.find(self._source.lower()) != -1: 2130 | # don't trigger new track flag for icecast streams where track name contains station name or source name; save some energy by not quering last.fm with this 2131 | self._media_image_url = None 2132 | return False 2133 | 2134 | if self._media_artist != self._media_prev_artist or self._media_title != self._media_prev_title: 2135 | return True 2136 | else: 2137 | return False 2138 | 2139 | async def async_set_multiroom_group(self, multiroom_group): 2140 | """Set multiroom group info.""" 2141 | self._multiroom_group = multiroom_group 2142 | 2143 | async def async_set_master(self, master): 2144 | """Set master device for multiroom configuration.""" 2145 | self._master = master 2146 | 2147 | async def async_set_is_master(self, is_master): 2148 | """Set master device for multiroom configuration.""" 2149 | self._is_master = is_master 2150 | 2151 | async def async_set_multiroom_unjoinat(self, tme): 2152 | """The moment when unjoin has happened. Needs some time for the MCU to finish unjoining first""" 2153 | self._multiroom_unjoinat = tme 2154 | 2155 | async def async_set_slave_mode(self, slave_mode): 2156 | """Set current device as slave in a multiroom configuration.""" 2157 | self._slave_mode = slave_mode 2158 | ##await self.async_schedule_update_ha_state(True) 2159 | 2160 | async def async_set_previous_source(self, srcbool): 2161 | """Memorize what was the previous source before entering multiroom.""" 2162 | if srcbool: 2163 | self._multiroom_prevsrc = self._source 2164 | else: 2165 | self._multiroom_prevsrc = None 2166 | 2167 | async def async_restore_previous_source(self): 2168 | """Set to the last known source after exiting multiroom.""" 2169 | self.select_source(self._multiroom_prevsrc) 2170 | self._multiroom_prevsrc = None 2171 | 2172 | async def async_set_media_title(self, title): 2173 | """Set the media title property.""" 2174 | self._media_title = title 2175 | 2176 | async def async_set_media_artist(self, artist): 2177 | """Set the media artist property.""" 2178 | self._media_artist = artist 2179 | 2180 | async def async_set_volume(self, volume): 2181 | """Set the volume property.""" 2182 | self._volume = volume 2183 | 2184 | async def async_set_muted(self, mute): 2185 | """Set the muted property.""" 2186 | self._muted = mute 2187 | 2188 | async def async_set_state(self, state): 2189 | """Set the state property.""" 2190 | self._state = state 2191 | 2192 | async def async_set_slave_ip(self, slave_ip): 2193 | """Set the slave ip property.""" 2194 | self._slave_ip = slave_ip 2195 | 2196 | async def async_set_playhead_position(self, position): 2197 | """Set the playhead position property.""" 2198 | self._playhead_position = position 2199 | 2200 | async def async_set_duration(self, duration): 2201 | """Set the duration property.""" 2202 | self._duration = duration 2203 | 2204 | async def async_set_position_updated_at(self, time): 2205 | """Set the position updated at property.""" 2206 | self._position_updated_at = time 2207 | 2208 | async def async_set_source(self, source): 2209 | """Set the source property.""" 2210 | self._source = source 2211 | ##await self.async_schedule_update_ha_state(True) 2212 | 2213 | async def async_set_sound_mode(self, mode): 2214 | """Set the sound mode property.""" 2215 | self._sound_mode = mode 2216 | 2217 | async def async_set_media_image_url(self, url): 2218 | """Set the media image URL property.""" 2219 | self._media_image_url = url 2220 | 2221 | async def async_set_media_uri(self, uri): 2222 | """Set the media URL property.""" 2223 | self._media_uri = uri 2224 | 2225 | async def async_set_features(self, features): 2226 | """Set the self features property.""" 2227 | self._features = features 2228 | 2229 | async def async_set_wait_for_mcu(self, wait_for_mcu): 2230 | """Set the wait for mcu processing duration property.""" 2231 | self._wait_for_mcu = wait_for_mcu 2232 | 2233 | async def async_set_unav_throttle(self, unav_throttle): 2234 | """Set update throttle property.""" 2235 | self._unav_throttle = unav_throttle 2236 | 2237 | async def async_preset_button(self, preset): 2238 | """Simulate pressing a physical preset button.""" 2239 | if self._preset_key != None and preset != None: 2240 | if not self._slave_mode: 2241 | if int(preset) > 0 and int(preset) <= self._preset_key: 2242 | value = await self.call_linkplay_httpapi("MCUKeyShortClick:{0}".format(str(preset)), None) 2243 | # self._wait_for_mcu = 2 2244 | # await self.async_schedule_update_ha_state(True) 2245 | if value != "OK": 2246 | _LOGGER.warning("Failed to recall preset %s. " "Device: %s, Got response: %s", self.entity_id, preset, value) 2247 | else: 2248 | _LOGGER.warning("Wrong preset number %s. Device: %s, has to be integer between 1 and %s", self.entity_id, preset, self._preset_key) 2249 | else: 2250 | await self._master.async_preset_button(preset) 2251 | 2252 | async def async_join_players(self, slaves): 2253 | """Join `group_members` as a player group with the current player (standard HA).""" 2254 | entities = self.hass.data[DOMAIN].entities 2255 | entities = [e for e in entities if e.entity_id in slaves] 2256 | await self.async_join(entities) 2257 | 2258 | async def async_join(self, slaves): 2259 | """Add selected slaves to multiroom configuration (original implementation).""" 2260 | _LOGGER.debug("Multiroom JOIN request: Master: %s, Slaves: %s", self.entity_id, slaves) 2261 | if self._state == STATE_UNAVAILABLE: 2262 | return 2263 | 2264 | if self.entity_id not in self._multiroom_group: 2265 | self._multiroom_group.append(self.entity_id) 2266 | self._is_master = True 2267 | self._wait_for_mcu = 2 2268 | 2269 | for slave in slaves: 2270 | if slave._is_master: 2271 | _LOGGER.debug("Multiroom: slave has master flag set. Unjoining it from where it is. Master: %s, Slave: %s", self.entity_id, slave.entity_id) 2272 | await slave.async_unjoin_all() 2273 | 2274 | if slave.entity_id not in self._multiroom_group: 2275 | if slave._slave_mode: 2276 | _LOGGER.debug("Multiroom: slave already has slave flag set. Unjoining it from where it is. Master: %s, Slave: %s", self.entity_id, slave.entity_id) 2277 | await slave.async_unjoin_me() 2278 | 2279 | await slave.async_set_previous_source(True) 2280 | if self._multiroom_wifidirect: 2281 | _LOGGER.debug("Multiroom: Join in WiFi drect mode. Master: %s, Slave: %s", self.entity_id, slave.entity_id) 2282 | cmd = "ConnectMasterAp:ssid={0}:ch={1}:auth=OPEN:".format(self._ssid, self._wifi_channel) + "encry=NONE:pwd=:chext=0" 2283 | else: 2284 | _LOGGER.debug("Multiroom: Join in multiroom mode. Master: %s, Slave: %s", self.entity_id, slave.entity_id) 2285 | cmd = 'ConnectMasterAp:JoinGroupMaster:eth{0}:wifi0.0.0.0'.format(self._host) 2286 | 2287 | value = await slave.call_linkplay_httpapi(cmd, None) 2288 | 2289 | _LOGGER.debug("Multiroom: command result: %s Master: %s, Slave: %s", value, self.entity_id, slave.entity_id) 2290 | if value == "OK": 2291 | # await slave.async_set_volume(self._volume) 2292 | # await slave.async_set_volume_level(self._volume) 2293 | await slave.async_set_master(self) 2294 | await slave.async_set_is_master(False) 2295 | await slave.async_set_slave_mode(True) 2296 | await slave.async_set_media_title(self._media_title) 2297 | await slave.async_set_media_artist(self._media_artist) 2298 | # await slave.async_set_muted(self._muted) 2299 | await slave.async_set_state(self.state) 2300 | await slave.async_set_slave_ip(self._host) 2301 | await slave.async_set_media_image_url(self._media_image_url) 2302 | await slave.async_set_playhead_position(self.media_position) 2303 | await slave.async_set_duration(self.media_duration) 2304 | await slave.async_set_source(self._source) 2305 | await slave.async_set_sound_mode(self._sound_mode) 2306 | await slave.async_set_features(self._features) 2307 | self._multiroom_group.append(slave.entity_id) 2308 | else: 2309 | await slave.async_set_previous_source(False) 2310 | _LOGGER.warning("Failed to join multiroom. command result: %s Master: %s, Slave: %s", value, self.entity_id, slave.entity_id) 2311 | 2312 | for slave in slaves: 2313 | if slave.entity_id in self._multiroom_group: 2314 | await slave.async_set_multiroom_group(self._multiroom_group) 2315 | ## await slave.async_set_position_updated_at(utcnow()) 2316 | ## await slave.async_trigger_schedule_update(True) 2317 | 2318 | self._position_updated_at = utcnow() 2319 | # await self.async_schedule_update_ha_state(True) 2320 | 2321 | async def async_unjoin_all(self): 2322 | """Disconnect everybody from the multiroom configuration because i'm the master.""" 2323 | if self._state == STATE_UNAVAILABLE: 2324 | return 2325 | 2326 | cmd = "multiroom:Ungroup" 2327 | value = await self.call_linkplay_httpapi(cmd, None) 2328 | if value == "OK": 2329 | self._is_master = False 2330 | for slave_id in self._multiroom_group: 2331 | for device in self.hass.data[DOMAIN].entities: 2332 | if device.entity_id == slave_id and device.entity_id != self.entity_id: 2333 | await device.async_set_slave_mode(False) 2334 | await device.async_set_is_master(False) 2335 | await device.async_set_slave_ip(None) 2336 | await device.async_set_master(None) 2337 | await device.async_set_multiroom_unjoinat(utcnow()) 2338 | await device.async_set_multiroom_group([]) 2339 | # await device.async_trigger_schedule_update(True) 2340 | self._multiroom_group = [] 2341 | self._position_updated_at = utcnow() 2342 | # await self.async_schedule_update_ha_state(True) 2343 | 2344 | else: 2345 | _LOGGER.warning("Failed to unjoin_all multiroom. " "Device: %s, Got response: %s", self.entity_id, value) 2346 | 2347 | async def async_unjoin_player(self): 2348 | """Remove this player from any group (standard HA).""" 2349 | if self._is_master: 2350 | await self.async_unjoin_all() 2351 | 2352 | if self._slave_mode: 2353 | await self.async_unjoin_me() 2354 | 2355 | async def async_unjoin_me(self): 2356 | """Disconnect myself from the multiroom configuration.""" 2357 | if self._multiroom_wifidirect: 2358 | for dev in self._multiroom_group: 2359 | for device in self.hass.data[DOMAIN].entities: 2360 | if device._is_master: ## TODO!!! 2361 | cmd = "multiroom:SlaveKickout:{0}".format(self._slave_ip) 2362 | value = await self._master.call_linkplay_httpapi(cmd, None) 2363 | self._master._position_updated_at = utcnow() 2364 | 2365 | else: 2366 | cmd = "multiroom:Ungroup" 2367 | value = await self.call_linkplay_httpapi(cmd, None) 2368 | 2369 | if value == "OK": 2370 | if self._master is not None: 2371 | await self._master.async_remove_from_group(self) 2372 | self._master._wait_for_mcu = 1 2373 | # await self._master.async_schedule_update_ha_state(True) 2374 | self._multiroom_unjoinat = utcnow() 2375 | self._master = None 2376 | self._is_master = False 2377 | self._slave_mode = False 2378 | self._slave_ip = None 2379 | self._multiroom_group = [] 2380 | # await self.async_schedule_update_ha_state(True) 2381 | 2382 | else: 2383 | _LOGGER.warning("Failed to unjoin_me from multiroom. " "Device: %s, Got response: %s", self.entity_id, value) 2384 | 2385 | async def async_remove_from_group(self, device): 2386 | """Remove a certain device for multiroom lists.""" 2387 | if device.entity_id in self._multiroom_group: 2388 | self._multiroom_group.remove(device.entity_id) 2389 | # await self.async_schedule_update_ha_state(True) 2390 | 2391 | if len(self._multiroom_group) <= 1: 2392 | self._multiroom_group = [] 2393 | self._is_master = False 2394 | self._slave_list = None 2395 | 2396 | for member in self._multiroom_group: 2397 | for player in self.hass.data[DOMAIN].entities: 2398 | if player.entity_id == member and player.entity_id != self.entity_id: 2399 | await player.async_set_multiroom_group(self._multiroom_group) 2400 | # await player.async_trigger_schedule_update(True) 2401 | # await player.async_set_position_updated_at(utcnow()) 2402 | 2403 | async def async_execute_command(self, command, notif): 2404 | """Execute desired command against the player using factory API.""" 2405 | if command.startswith('MCU'): 2406 | value = await self.call_linkplay_tcpuart(command) 2407 | elif command == 'PromptEnable': 2408 | value = await self.call_linkplay_httpapi("PromptEnable", None) 2409 | elif command == 'PromptDisable': 2410 | value = await self.call_linkplay_httpapi("PromptDisable", None) 2411 | elif command == 'RouterMultiroomEnable': 2412 | value = await self.call_linkplay_httpapi("setMultiroomLogic:1", None) 2413 | elif command == 'SetRandomWifiKey': 2414 | from random import choice 2415 | from string import ascii_letters 2416 | newkey = (''.join(choice(ascii_letters) for i in range(16))) 2417 | value = await self.call_linkplay_httpapi("setNetwork:1:{0}".format(newkey), None) 2418 | if value == 'OK': 2419 | value = value + ", key: " + newkey 2420 | else: 2421 | value = "key: " + newkey 2422 | elif command.startswith('SetApSSIDName:'): 2423 | ssidnam = command.replace('SetApSSIDName:', '').strip() 2424 | if ssidnam != '': 2425 | value = await self.call_linkplay_httpapi("setSSID:{0}".format(ssidnam), None) 2426 | if value == 'OK': 2427 | value = value + ", SoftAP SSID set to: " + ssidnam 2428 | else: 2429 | value == "SSID not specified correctly. You need 'SetApSSIDName: NewWifiName'" 2430 | elif command.startswith('WriteDeviceNameToUnit:'): 2431 | devnam = command.replace('WriteDeviceNameToUnit:', '').strip() 2432 | if devnam != '': 2433 | value = await self.call_linkplay_httpapi("setDeviceName:{0}".format(devnam), None) 2434 | if value == 'OK': 2435 | self._name = devnam 2436 | value = value + ", name set to: " + self._name 2437 | else: 2438 | value == "Device name not specified correctly. You need 'WriteDeviceNameToUnit: My Device Name'" 2439 | elif command == 'TimeSync': 2440 | import time 2441 | tme = time.strftime('%Y%m%d%H%M%S') 2442 | value = await self.call_linkplay_httpapi("timeSync:{0}".format(tme), None) 2443 | if value == 'OK': 2444 | value = value + ", time: " + tme 2445 | elif command == 'Rescan': 2446 | self._unav_throttle = False 2447 | self._first_update = True 2448 | # await self.async_schedule_update_ha_state(True) 2449 | value = "Scheduled to Rescan" 2450 | elif command == 'Update': 2451 | # await self.async_schedule_update_ha_state(True) 2452 | value = "Scheduled to Update state" 2453 | elif command == 'reboot': 2454 | value = await self.call_linkplay_httpapi("reboot;", None) 2455 | elif command == 'restoreToDefault': 2456 | value = await self.call_linkplay_httpapi("restoreToDefault", None) 2457 | else: 2458 | value = "No such command implemented." 2459 | _LOGGER.warning("Player %s command: %s, result: %s", self.entity_id, command, value) 2460 | 2461 | _LOGGER.debug("Player %s executed command: %s, result: %s", self.entity_id, command, value) 2462 | 2463 | if notif: 2464 | self.hass.components.persistent_notification.async_create("Executed command:
{0}
Result:
{1}".format(command, value), title=self.entity_id) 2465 | 2466 | async def async_snapshot(self, switchinput): 2467 | """Snapshot the current input source and the volume level of it """ 2468 | if self._state == STATE_UNAVAILABLE: 2469 | return 2470 | 2471 | if not self._slave_mode: 2472 | self._snapshot_active = True 2473 | self._snap_source = self._source 2474 | self._snap_state = self._state 2475 | self._snap_nometa = self._nometa 2476 | self._snap_playing_mediabrowser = self._playing_mediabrowser 2477 | self._snap_media_source_uri = self._media_source_uri 2478 | self._snap_playhead_position = self._playhead_position 2479 | 2480 | if self._playing_localfile or self._playing_spotify or self._playing_webplaylist: 2481 | if self._state in [STATE_PLAYING, STATE_PAUSED]: 2482 | self._snap_seek = True 2483 | 2484 | elif self._playing_stream or self._playing_mediabrowser: 2485 | if self._state in [STATE_PLAYING, STATE_PAUSED] and self._playing_mediabrowser: 2486 | self._snap_seek = True 2487 | 2488 | _LOGGER.debug("Player %s snaphsot source: %s, volume: %s, uri: %s, seek: %s, pos: %s", self.name, self._source, self._snap_volume, self._media_uri_final, self._snap_seek, self._playhead_position) 2489 | 2490 | if self._source == "Network": 2491 | self._snap_uri = self._media_uri_final 2492 | 2493 | 2494 | if self._playing_spotify: 2495 | if not switchinput: 2496 | await self.async_preset_snap_via_upnp(str(self._preset_key)) 2497 | await self.call_linkplay_httpapi("setPlayerCmd:stop", None) 2498 | else: 2499 | self._snap_spotify_volumeonly = True 2500 | self._snap_spotify = True 2501 | self._snap_volume = int(self._volume) 2502 | # await asyncio.sleep(0.2) 2503 | return 2504 | 2505 | elif self._state == STATE_IDLE: 2506 | self._snap_volume = int(self._volume) 2507 | 2508 | elif switchinput and not self._playing_stream: 2509 | value = await self.call_linkplay_httpapi("setPlayerCmd:switchmode:wifi", None) 2510 | await asyncio.sleep(0.2) 2511 | await self.call_linkplay_httpapi("setPlayerCmd:stop", None) 2512 | if value == "OK": 2513 | await asyncio.sleep(2) # have to wait for the sound fade-in of the unit when physical source is changed, otherwise volume value will be incorrect 2514 | await self.async_get_status() 2515 | if self._player_statdata is not None: 2516 | try: 2517 | self._snap_volume = int(self._player_statdata['vol']) 2518 | except ValueError: 2519 | _LOGGER.warning("Erroneous JSON during snapshot volume reading: %s, %s", self.entity_id, self._name) 2520 | self._snap_volume = 0 2521 | else: 2522 | self._snap_volume = 0 2523 | else: 2524 | self._snap_volume = 0 2525 | else: 2526 | self._snap_volume = int(self._volume) 2527 | if self._fwvercheck(self._fw_ver) >= self._fwvercheck(FW_SLOW_STREAMS): 2528 | await self.call_linkplay_httpapi("setPlayerCmd:pause", None) 2529 | await self.call_linkplay_httpapi("setPlayerCmd:stop", None) 2530 | else: 2531 | return 2532 | #await self._master.async_snapshot(switchinput) 2533 | 2534 | 2535 | async def async_restore(self): 2536 | """Restore the current input source and the volume level of it """ 2537 | if self._state == STATE_UNAVAILABLE: 2538 | return 2539 | 2540 | if not self._slave_mode: 2541 | _LOGGER.debug("Player %s current source: %s, restoring volume: %s, source: %s uri: %s, seek: %s, pos: %s", self.name, self._source, self._snap_volume, self._snap_source, self._snap_uri, self._snap_seek, self._snap_playhead_position) 2542 | if self._snap_state != STATE_UNKNOWN: 2543 | self._state = self._snap_state 2544 | 2545 | if self._snap_volume != 0: 2546 | await self.call_linkplay_httpapi("setPlayerCmd:vol:{0}".format(str(self._snap_volume)), None) 2547 | self._snap_volume = 0 2548 | 2549 | # await asyncio.sleep(.6) 2550 | 2551 | self._playing_tts = False 2552 | self._playhead_position = self._snap_playhead_position 2553 | 2554 | if self._snap_spotify: 2555 | self._snap_spotify = False 2556 | if not self._snap_spotify_volumeonly: 2557 | await self.call_linkplay_httpapi("MCUKeyShortClick:{0}".format(str(self._preset_key)), None) 2558 | self._snapshot_active = False 2559 | self._snap_spotify_volumeonly = False 2560 | # await self.async_schedule_update_ha_state(True) 2561 | 2562 | elif self._snap_source != "Network": 2563 | self._snapshot_active = False 2564 | await self.async_select_source(self._snap_source) 2565 | self._snap_source = None 2566 | 2567 | elif self._snap_uri is not None: 2568 | self._playing_mediabrowser = self._snap_playing_mediabrowser 2569 | self._media_source_uri = self._snap_media_source_uri 2570 | self._media_uri = self._snap_uri 2571 | self._nometa = self._snap_nometa 2572 | if self._snap_state in [STATE_PLAYING, STATE_PAUSED]: # self._media_uri.find('tts_proxy') == -1 2573 | await self.async_play_media(MEDIA_TYPE_URL, self._media_uri) 2574 | self._snapshot_active = False 2575 | self._snap_uri = None 2576 | 2577 | if self._snap_state in [STATE_PLAYING, STATE_PAUSED]: 2578 | await asyncio.sleep(0.5) 2579 | if self._snap_seek and self._snap_playhead_position > 0: 2580 | _LOGGER.debug("Seekin'") 2581 | await self.call_linkplay_httpapi("setPlayerCmd:seek:{0}".format(str(self._snap_playhead_position)), None) 2582 | if self._snap_state == STATE_PAUSED: 2583 | await self.async_media_pause() 2584 | 2585 | self._snap_state = STATE_UNKNOWN 2586 | self._snap_seek = False 2587 | self._snap_playhead_position = 0 2588 | 2589 | else: 2590 | return 2591 | #await self._master.async_restore() 2592 | 2593 | async def async_play_track(self, track): 2594 | """Play media track by name found in the tracks list.""" 2595 | if not len(self._trackq) > 0 or track is None: 2596 | return 2597 | 2598 | track.hass = self.hass # render template 2599 | trackn = track.async_render() 2600 | 2601 | if not self._slave_mode: 2602 | try: 2603 | index = [idx for idx, s in enumerate(self._trackq) if trackn in s][0] 2604 | except (IndexError): 2605 | return 2606 | 2607 | if not index > 0: 2608 | return 2609 | 2610 | value = await self.call_linkplay_httpapi("setPlayerCmd:playLocalList:{0}".format(index), None) 2611 | if value != "OK": 2612 | _LOGGER.warning("Failed to play media track by name. Device: %s, Got response: %s", self.entity_id, value) 2613 | return False 2614 | else: 2615 | self._state = STATE_PLAYING 2616 | self._playing_tts = False 2617 | #self._wait_for_mcu = 0.4 2618 | self._media_title = None 2619 | self._media_artist = None 2620 | self._media_album = None 2621 | self._trackc = None 2622 | self._icecast_name = None 2623 | self._playhead_position = 0 2624 | self._duration = 0 2625 | self._position_updated_at = utcnow() 2626 | self._media_image_url = None 2627 | self._media_uri = None 2628 | self._media_uri_final = None 2629 | self._ice_skip_throt = False 2630 | self._unav_throttle = False 2631 | # await self.async_schedule_update_ha_state(True) 2632 | return True 2633 | else: 2634 | await self._master.async_play_track(track) 2635 | 2636 | async def async_update_via_upnp(self): 2637 | """Update track info via UPNP.""" 2638 | import validators 2639 | radio = False 2640 | 2641 | if self._upnp_device is None: 2642 | return 2643 | 2644 | self._service = self._upnp_device.service('urn:schemas-upnp-org:service:AVTransport:1') 2645 | #_LOGGER.debug("GetMediaInfo for: %s, UPNP service:%s", self.entity_id, self._service) 2646 | 2647 | media_info = dict() 2648 | media_metadata = None 2649 | try: 2650 | media_info = await self._service.action("GetMediaInfo").async_call(InstanceID=0) 2651 | self._trackc = media_info.get('CurrentURI') 2652 | self._media_uri_final = media_info.get('TrackSource') 2653 | media_metadata = media_info.get('CurrentURIMetaData') 2654 | #_LOGGER.debug("GetMediaInfo for: %s, UPNP media_metadata:%s", self.entity_id, media_info) 2655 | except: 2656 | _LOGGER.warning("GetMediaInfo/CurrentURIMetaData UPNP error: %s", self.entity_id) 2657 | 2658 | if media_metadata is None: 2659 | return 2660 | 2661 | self._media_title = None 2662 | self._media_album = None 2663 | self._media_artist = None 2664 | self._media_image_url = None 2665 | 2666 | xml_tree = ET.fromstring(media_metadata) 2667 | 2668 | xml_path = "{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}item/" 2669 | title_xml_path = "{http://purl.org/dc/elements/1.1/}title" 2670 | artist_xml_path = "{urn:schemas-upnp-org:metadata-1-0/upnp/}artist" 2671 | album_xml_path = "{urn:schemas-upnp-org:metadata-1-0/upnp/}album" 2672 | image_xml_path = "{urn:schemas-upnp-org:metadata-1-0/upnp/}albumArtURI" 2673 | radiosub_xml_path = "{http://purl.org/dc/elements/1.1/}subtitle" 2674 | 2675 | if radio: 2676 | title = xml_tree.find("{0}{1}".format(xml_path, radiosub_xml_path)).text 2677 | if title.find(' - ') != -1: 2678 | titles = title.split(' - ') 2679 | self._media_artist = string.capwords(titles[0].strip()) 2680 | self._media_title = string.capwords(titles[1].strip()) 2681 | else: 2682 | self._media_title = string.capwords(title.strip()) 2683 | else: 2684 | self._media_title = xml_tree.find("{0}{1}".format(xml_path, title_xml_path)).text 2685 | self._media_artist = xml_tree.find("{0}{1}".format(xml_path, artist_xml_path)).text 2686 | self._media_album = xml_tree.find("{0}{1}".format(xml_path, album_xml_path)).text 2687 | 2688 | self._media_image_url = xml_tree.find("{0}{1}".format(xml_path, image_xml_path)).text 2689 | 2690 | if not validators.url(self._media_image_url): 2691 | self._media_image_url = None 2692 | 2693 | async def async_tracklist_via_upnp(self, media): 2694 | """Retrieve tracks list queue via UPNP.""" 2695 | if self._upnp_device is None: 2696 | return 2697 | 2698 | if media == 'USB': 2699 | queuename = 'USBDiskQueue' # 'CurrentQueue' # 'USBDiskQueue' 2700 | rootdir = ROOTDIR_USB 2701 | else: 2702 | _LOGGER.debug("Tracklist retrieval: %s, %s is not supported. You can use only 'USB' for now.", self.entity_id, media) 2703 | self._trackq = [] 2704 | return 2705 | 2706 | self._service = self._upnp_device.service('urn:schemas-wiimu-com:service:PlayQueue:1') 2707 | #_LOGGER.debug("PlayQueue for: %s, UPNP service:%s", self.entity_id, self._service) 2708 | 2709 | media_info = dict() 2710 | media_metadata = None 2711 | try: 2712 | media_info = await self._service.action("BrowseQueue").async_call(QueueName=queuename) 2713 | media_metadata = media_info.get('QueueContext') 2714 | #_LOGGER.debug("PlayQueue for: %s, UPNP media_metadata:%s", self.entity_id, media_info) 2715 | except: 2716 | _LOGGER.debug("PlayQueue/QueueContext UPNP error, media not present?: %s", self.entity_id) 2717 | 2718 | if media_metadata is None: 2719 | return 2720 | 2721 | xml_tree = ET.fromstring(media_metadata) 2722 | 2723 | trackq = [] 2724 | for playlist in xml_tree: 2725 | for tracks in playlist: 2726 | for track in tracks: 2727 | if track.tag == 'URL': 2728 | if rootdir in track.text: 2729 | tracku = track.text.replace(rootdir, '') 2730 | trackq.append(tracku) 2731 | 2732 | if len(trackq) > 0: 2733 | self._trackq = trackq 2734 | 2735 | async def async_preset_snap_via_upnp(self, presetnum): 2736 | """Save current playlist to a preset via UPNP.""" 2737 | if self._upnp_device is None or not self._playing_spotify: 2738 | return 2739 | 2740 | media_info = dict() 2741 | 2742 | self._service = self._upnp_device.service('urn:schemas-wiimu-com:service:PlayQueue:1') 2743 | #_LOGGER.debug("PlayQueue for: %s, UPNP service:%s", self.entity_id, self._service) 2744 | 2745 | try: 2746 | media_info = await self._service.action("SetSpotifyPreset").async_call(KeyIndex=int(presetnum)) 2747 | _LOGGER.debug("PlayQueue/SetSpotifyPreset for: %s, UPNP media_info:%s", self.entity_id, media_info) 2748 | result = str(media_info.get('Result')) 2749 | except: 2750 | _LOGGER.debug("SetSpotifyPreset UPNP error for: %s, presetnum: %s, result: %s", self.entity_id, presetnum, result) 2751 | return 2752 | 2753 | preset_map = dict() 2754 | 2755 | try: 2756 | preset_map = await self._service.action("GetKeyMapping").async_call() 2757 | preset_map = preset_map.get('QueueContext') 2758 | except: 2759 | _LOGGER.debug("GetKeyMapping UPNP error: %s", self.entity_id) 2760 | return 2761 | 2762 | xml_tree = ET.fromstring(preset_map) 2763 | 2764 | if xml_tree.find('Key'+presetnum) is None: 2765 | _LOGGER.error("Preset Map error: %s num: %s. Please create a Spotify preset first with the mobile app for this player. Tree: %s", self.entity_id, presetnum, preset_map) 2766 | self.hass.components.persistent_notification.async_create("Preset Map error:


This player can't store presets yet!
Please create a preset first manually with the mobile app for this player and then try again.", title=self.entity_id) 2767 | return 2768 | 2769 | import time 2770 | tme = time.strftime('%Y-%m-%d %H:%M:%S') 2771 | 2772 | try: 2773 | xml_tree.find('Key'+presetnum+'/Name').text = "Snapshot set by Home Assistant ("+result+")_#~" + tme 2774 | except: 2775 | data=xml_tree.find('Key'+presetnum) 2776 | snap=ET.SubElement(data,'Name') 2777 | snap.text = "Snapshot set by Home Assistant ("+result+")_#~" + tme 2778 | 2779 | try: 2780 | xml_tree.find('Key'+presetnum+'/Source').text = "SPOTIFY" 2781 | except: 2782 | data=xml_tree.find('Key'+presetnum) 2783 | snap=ET.SubElement(data,'Source') 2784 | snap.text = "SPOTIFY" 2785 | 2786 | try: 2787 | xml_tree.find('Key'+presetnum+'/PicUrl').text = "https://brands.home-assistant.io/_/media_player/icon.png" 2788 | except: 2789 | data=xml_tree.find('Key'+presetnum) 2790 | snap=ET.SubElement(data,'PicUrl') 2791 | snap.text = "https://brands.home-assistant.io/_/media_player/icon.png" 2792 | 2793 | preset_map = ET.tostring(xml_tree, encoding='unicode') 2794 | 2795 | try: 2796 | await self._service.action("SetKeyMapping").async_call(QueueContext=preset_map) 2797 | except: 2798 | _LOGGER.debug("SetKeyMapping UPNP error: %s, %s", self.entity_id, preset_map) 2799 | return 2800 | 2801 | 2802 | async def async_browse_media(self, media_content_type=None, media_content_id=None): 2803 | """Implement the websocket media browsing helper.""" 2804 | return await media_source.async_browse_media( 2805 | self.hass, 2806 | media_content_id, 2807 | content_filter=lambda item: item.media_content_type.startswith("audio/"), 2808 | ) 2809 | 2810 | #TODO: combide the BrowseMedia Media Sources above with the BrowseMedia Directory below 2811 | #if "udisk" in self._source_list: 2812 | # if media_content_id not in (None, "root"): 2813 | # raise BrowseError( 2814 | # f"Media not found: {media_content_type} / {media_content_id}" 2815 | # ) 2816 | 2817 | # source_media_name = self._source_list.get("udisk", "USB Disk") 2818 | 2819 | # if len(self._trackq) > 0: 2820 | # radio = [ 2821 | # BrowseMedia( 2822 | # title = preset, 2823 | # media_class = MEDIA_CLASS_MUSIC, 2824 | # media_content_id = index, 2825 | # media_content_type = MEDIA_TYPE_MUSIC, 2826 | # can_play = True, 2827 | # can_expand = False, 2828 | # ) 2829 | # for index, preset in enumerate(self._trackq, start=1) 2830 | # ] 2831 | 2832 | # root = BrowseMedia( 2833 | # title=self._name + " " + source_media_name, 2834 | # media_class = MEDIA_CLASS_DIRECTORY, 2835 | # media_content_id = "root", 2836 | # media_content_type = "listing", 2837 | # can_play = False, 2838 | # can_expand = True, 2839 | # children = radio, 2840 | # ) 2841 | 2842 | # else: 2843 | # root = BrowseMedia( 2844 | # title=self._name + " " + source_media_name, 2845 | # media_class = MEDIA_CLASS_DIRECTORY, 2846 | # media_content_id = "root", 2847 | # media_content_type = "listing", 2848 | # can_play = False, 2849 | # can_expand = False, 2850 | # ) 2851 | 2852 | # return root 2853 | #END 2854 | -------------------------------------------------------------------------------- /custom_components/linkplay/services.yaml: -------------------------------------------------------------------------------- 1 | join: 2 | name: Join 3 | description: Group players together in a multiroom setup. 4 | fields: 5 | master: 6 | name: Master Entity ID 7 | description: Entity ID of the player that should become the master of the group. 8 | example: media_player.sound_room2 9 | required: true 10 | selector: 11 | entity: 12 | integration: linkplay 13 | entity_id: 14 | name: Slave Entity ID 15 | description: Entity ID(s) of the player(s) that will connect to the master. Switch to YAML mode to manually add more, separated by comma. 16 | example: media_player.sound_room1 17 | required: true 18 | selector: 19 | entity: 20 | integration: linkplay 21 | 22 | unjoin: 23 | name: Unjoin 24 | description: Unjoin a player or all players from the multiroom setup. 25 | fields: 26 | entity_id: 27 | name: Entity ID 28 | description: Entity ID(s) of the player(s) that will be unjoined from the group. If this is a master, all slaves will be unjoined. 29 | example: media_player.sound_room2 30 | required: true 31 | selector: 32 | entity: 33 | integration: linkplay 34 | 35 | snapshot: 36 | name: Snapshot 37 | description: Prepare the player to play TTS and save the current state of it for restore afterwards. Current playback will stop. 38 | fields: 39 | entity_id: 40 | name: Entity ID 41 | description: Entity ID of the player of which the snapshot should be saved. 42 | example: media_player.sound_room1 43 | required: true 44 | selector: 45 | entity: 46 | integration: linkplay 47 | switchinput: 48 | name: Switch Input / Volume Only 49 | description: To be used with Spotify Integration. Switch player to stream input along with snapshotting, before playing TTS. Applies for players with multiple inputs like Line-in, Optical, etc. Optional - if not specified, defaults to True. 50 | example: false 51 | required: false 52 | default: false 53 | selector: 54 | boolean: 55 | 56 | restore: 57 | name: Restore 58 | description: Restore the state of the player after playing TTS, from a saved snapshot. 59 | fields: 60 | entity_id: 61 | name: Entity ID 62 | description: Entity ID of the player of which the snapshot should be restored. 63 | example: media_player.sound_room1 64 | required: true 65 | selector: 66 | entity: 67 | integration: linkplay 68 | 69 | preset: 70 | name: Preset 71 | description: Recall content preset from the device. 72 | fields: 73 | entity_id: 74 | name: Entity ID 75 | description: Entity ID of the player for which the preset will be recalled. 76 | example: media_player.sound_room1 77 | required: true 78 | selector: 79 | entity: 80 | integration: linkplay 81 | preset: 82 | name: Preset 83 | description: Content preset number on the device 84 | example: 1 85 | required: true 86 | default: 1 87 | selector: 88 | number: 89 | min: 1 90 | max: 36 91 | mode: box 92 | command: 93 | name: Command 94 | description: Execute various linkplay-specific commands on the player. 95 | fields: 96 | entity_id: 97 | name: Entity ID 98 | description: Entity ID of the player against which the command wil be execuded. 99 | example: media_player.sound_room1 100 | required: true 101 | selector: 102 | entity: 103 | integration: linkplay 104 | command: 105 | name: Command 106 | description: 'To set the names for WriteDeviceNameToUnit and SetApSSIDName please switch to YAML mode and enter the name there.' 107 | example: Update 108 | required: true 109 | selector: 110 | select: 111 | options: 112 | - Rescan 113 | - PromptEnable 114 | - PromptDisable 115 | - RouterMultiroomEnable 116 | - SetRandomWifiKey 117 | - TimeSync 118 | - restoreToDefault 119 | - reboot 120 | - 'WriteDeviceNameToUnit: My Device Name' 121 | - 'SetApSSIDName: NewWifiName' 122 | notify: 123 | name: Notification 124 | description: Displays the result of the command as a persistent notification in Lovelace UI (optional, defaults to True). Set to False during automations to avoid seeing these. 125 | example: false 126 | required: false 127 | default: true 128 | selector: 129 | boolean: 130 | 131 | play_track: 132 | name: Play track 133 | description: Play media track by name found in the tracks list. 134 | fields: 135 | entity_id: 136 | name: Entity ID 137 | description: Entity ID of the player on which the playback wil be execuded. 138 | example: media_player.sound_room1 139 | required: true 140 | selector: 141 | entity: 142 | integration: linkplay 143 | track: 144 | name: Track 145 | description: (Part of) The name of the track from the list 146 | example: 'Commodores - Machine Gun Extended Mix.mp3' 147 | required: true 148 | selector: 149 | text: 150 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Linkplay-based speakers and devices", 3 | "content_in_root": false, 4 | "render_readme": false, 5 | "domains": ["media_player"], 6 | "homeassistant": "2024.8.0" 7 | } 8 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Linkplay-based speakers and sound devices 2 | 3 | **The `linkplay` custom component for Home Assistant is deprecated, and unsupported! Starting with Home Assistant 2024.08 release, Linkplay chipset based media players are officially supported without the need of any custom component.** 4 | 5 | To switch to the official LinkPlay integration starting from **2024.08**, follow these steps: 6 | * Remove the current `linkplay` configuration from your configuration.yaml. 7 | * Restart Home-Assistant. 8 | * Delete the custom component through HACS or manually by deleting the `custom_components/linkplay` folder. 9 | * Restart Home-Assistant again. Your players will be automatically discovered, a notification popup will inform you on this. 10 | 11 | For any bugs, feature requests, or PRs, please use the official Home Assistant channels. 12 | --------------------------------------------------------------------------------