├── .github ├── CODEOWNERS ├── settings.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md └── stale.yml ├── requirements-dev.txt ├── hacs.json ├── requirements.txt ├── dev-setup.sh ├── custom_components └── linkplay │ ├── __init__.py │ ├── manifest.json │ └── media_player.py ├── .pre-commit-config.yaml ├── LICENSE ├── info.md ├── hass-integration-manifest.schema.json ├── CONTRIBUTING.md └── update_tracker.py /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @nicjo814 2 | * @limych 3 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pre-commit 2 | pylint 3 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "LinkPlay Sound Devices Integration", 3 | "domains": ["media_player"] 4 | } 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | geopy 2 | geohash2 3 | voluptuous 4 | homeassistant 5 | eyeD3~=0.8 6 | uPnPClient~=0.0 7 | validators~=0.12 8 | -------------------------------------------------------------------------------- /dev-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pip3 install -r requirements.txt -r requirements-dev.txt --user 4 | pre-commit install 5 | pre-commit autoupdate 6 | -------------------------------------------------------------------------------- /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://home-assistant.io/components/media_player.linkplay/ 6 | """ 7 | 8 | DOMAIN = 'linkplay' 9 | VERSION = '1.1.6' 10 | ISSUE_URL = 'https://github.com/Limych/media_player.linkplay/issues' 11 | 12 | DATA_LINKPLAY = DOMAIN 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: update-tracker 5 | name: "Update Tracker" 6 | entry: "./update_tracker.py" 7 | language: system 8 | - id: pylint 9 | name: pylint 10 | entry: python3 -m pylint.__main__ 11 | language: system 12 | types: [python] 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v2.4.0 15 | hooks: 16 | - id: check-json 17 | - id: check-yaml 18 | - id: trailing-whitespace 19 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | private: false 3 | has_issues: true 4 | has_projects: false 5 | has_wiki: false 6 | has_downloads: false 7 | default_branch: master 8 | allow_squash_merge: true 9 | allow_merge_commit: false 10 | allow_rebase_merge: false 11 | labels: 12 | - name: "Feature Request" 13 | color: "fbca04" 14 | - name: "Bug" 15 | color: "b60205" 16 | - name: "Wont Fix" 17 | color: "ffffff" 18 | - name: "Enhancement" 19 | color: "a2eeef" 20 | - name: "Documentation" 21 | color: "008672" 22 | - name: "Stale" 23 | color: "930191" 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /custom_components/linkplay/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "linkplay", 3 | "name": "LinkPlay Media Player", 4 | "documentation": "https://github.com/Limych/media_player.linkplay", 5 | "dependencies": [], 6 | "config_flow": false, 7 | "codeowners": [ 8 | "@nicjo814", 9 | "@limych" 10 | ], 11 | "requirements": [ 12 | "eyeD3~=0.8", 13 | "uPnPClient~=0.0", 14 | "validators~=0.12" 15 | ], 16 | "ssdp": { 17 | "st": [ 18 | "upnp:rootdevice" 19 | ], 20 | "manufacturer": [ 21 | "wiimu" 22 | ], 23 | "device_type": [ 24 | "urn:schemas-upnp-org:device:MediaRenderer:1" 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 14 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | #exemptLabels: 7 | # - pinned 8 | # - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: Stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: >- 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | 16 | 17 | ## Version of the custom_component 18 | 21 | 22 | ## Configuration 23 | 24 | ```yaml 25 | 26 | Add your configs here. 27 | 28 | ``` 29 | 30 | ## Describe the bug 31 | A clear and concise description of what the bug is. 32 | 33 | 34 | ## Debug log 35 | 36 | 37 | 38 | ```text 39 | 40 | Add your logs here. 41 | 42 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Niclas Berglind @nicjo814 4 | Copyright (c) 2019–2020 Andrey Khrolenok @Limych 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | This component allows you to integrate control of audio devices based on LinkPlay platform into your Home Assistant smart home system. 2 | 3 | ![](https://raw.githubusercontent.com/Limych/media_player.linkplay/master/docs/images/linkplay_logo.png) 4 | 5 | ![](https://raw.githubusercontent.com/Limych/media_player.linkplay/master/docs/images/linkplay_devices.png) 6 | 7 | ## Links 8 | 9 | - [Documentation](https://github.com/Limych/media_player.linkplay) 10 | - [Configuration](https://github.com/Limych/media_player.linkplay#configuration-variables) 11 | - [Report a Bug](https://github.com/Limych/media_player.linkplay/issues/new?template=issue.md) 12 | - [Suggest an idea](https://github.com/Limych/media_player.linkplay/issues/new?template=feature_request.md) 13 | 14 |

* * *

15 | I put a lot of work into making this repo and component available and updated to inspire and help others! I will be glad to receive thanks from you — it will give me new strength and add enthusiasm: 16 |


17 | Patreon 18 | PayPal 19 |
or support via Bitcoin or Etherium:
20 | Bitcoin
21 | 16yfCfz9dZ8y8yuSwBFVfiAa3CNYdMh7Ts
22 |

23 | -------------------------------------------------------------------------------- /hass-integration-manifest.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "https://github.com/Limych/ha-average/blob/master/hass-integration-manifest.schema.json", 4 | "title": "Home Assistant Integration Manifest", 5 | "description": "Manifest of integration for Home Assistant smart home system", 6 | "type": "object", 7 | "properties": { 8 | "domain": { 9 | "type": "string", 10 | "description": "The unique domain name of this integration. Cannot be changed" 11 | }, 12 | "name": { 13 | "type": "string", 14 | "description": "The name of the integration" 15 | }, 16 | "documentation": { 17 | "type": "string", 18 | "description": "URL of the website containing documentation on how to use that integration" 19 | }, 20 | "requirements": { 21 | "type": "array", 22 | "description": "List of required Python libraries or modules for this integration", 23 | "items": { 24 | "type": "string" 25 | }, 26 | "minItems": 0, 27 | "uniqueItems": true 28 | }, 29 | "dependencies": { 30 | "type": "array", 31 | "description": "List of the other Home Assistant integrations that need to Home Assistant to set up successfully prior to the integration being loaded", 32 | "items": { 33 | "type": "string" 34 | }, 35 | "minItems": 0, 36 | "uniqueItems": true 37 | }, 38 | "codeowners": { 39 | "type": "array", 40 | "description": "List of GitHub usernames or team names of people that are responsible for this integration", 41 | "items": { 42 | "type": "string" 43 | }, 44 | "minItems": 0, 45 | "uniqueItems": true 46 | } 47 | }, 48 | "required": [ 49 | "domain", 50 | "name", 51 | "documentation", 52 | "requirements", 53 | "dependencies", 54 | "codeowners" 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## IMPORTANT! Install development environment first 11 | 12 | When making changes in code, please use the existing development environment - this will save you from many errors and help create more convenient code to support. To install the environment, run the dev-setup.sh script. 13 | 14 | ## Github is used for everything 15 | 16 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 17 | 18 | Pull requests are the best way to propose changes to the codebase. 19 | 20 | 1. Fork the repo and create your branch from `master`. 21 | 2. If you've changed something, update the documentation. 22 | 3. Make sure your code lints (using black). 23 | 4. Issue that pull request! 24 | 25 | ## Any contributions you make will be under the MIT Software License 26 | 27 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 28 | 29 | ## Report bugs using Github's [issues](../../issues) 30 | 31 | GitHub issues are used to track public bugs. 32 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 33 | 34 | ## Write bug reports with detail, background, and sample code 35 | 36 | **Great Bug Reports** tend to have: 37 | 38 | - A quick summary and/or background 39 | - Steps to reproduce 40 | - Be specific! 41 | - Give sample code if you can. 42 | - What you expected would happen 43 | - What actually happens 44 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 45 | 46 | People *love* thorough bug reports. I'm not even kidding. 47 | 48 | ## Use a Consistent Coding Style 49 | 50 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 51 | 52 | ## License 53 | 54 | By contributing, you agree that your contributions will be licensed under its MIT License. 55 | -------------------------------------------------------------------------------- /update_tracker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Tracker updater for custom_updater.""" 3 | 4 | # Copyright (c) 2019, Andrey "Limych" Khrolenok 5 | # Creative Commons BY-NC-SA 4.0 International Public License 6 | # (see LICENSE.md or https://creativecommons.org/licenses/by-nc-sa/4.0/) 7 | import copy 8 | import json 9 | import logging 10 | import os 11 | import re 12 | 13 | # http://docs.python.org/2/howto/logging.html#library-config 14 | # Avoids spurious error messages if no logger is configured by the user 15 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 16 | 17 | # logging.basicConfig(level=logging.DEBUG) 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | TRACKER_FPATH = 'custom_components.json' if os.path.isfile('custom_components.json') \ 22 | else 'tracker.json' 23 | 24 | 25 | def fallback_version(localpath): 26 | """Return version from regex match.""" 27 | return_value = '' 28 | if os.path.isfile(localpath): 29 | with open(localpath, 'r') as local: 30 | ret = re.compile( 31 | r"^\b(VERSION|__version__)\s*=\s*['\"](.*)['\"]") 32 | for line in local.readlines(): 33 | matcher = ret.match(line) 34 | if matcher: 35 | return_value = str(matcher.group(2)) 36 | return return_value 37 | 38 | 39 | def get_component_version(localpath, name): 40 | """Return the local version if any.""" 41 | _LOGGER.debug('Started for %s', localpath) 42 | if '.' in name: 43 | name = "{}.{}".format(name.split('.')[1], name.split('.')[0]) 44 | return_value = '' 45 | if os.path.isfile(localpath): 46 | package = "custom_components.{}".format(name) 47 | try: 48 | name = "__version__" 49 | return_value = getattr( 50 | __import__(package, fromlist=[name]), name) 51 | except Exception as err: # pylint: disable=W0703 52 | _LOGGER.debug(str(err)) 53 | if return_value == '': 54 | try: 55 | name = "VERSION" 56 | return_value = getattr( 57 | __import__(package, fromlist=[name]), name) 58 | except Exception as err: # pylint: disable=W0703 59 | _LOGGER.debug(str(err)) 60 | if return_value == '': 61 | return_value = fallback_version(localpath) 62 | _LOGGER.debug(str(return_value)) 63 | return return_value 64 | 65 | 66 | def update_tracker(tracker_fpath): 67 | """Run tracker file update.""" 68 | with open(tracker_fpath, 'r') as tracker_file: 69 | tracker = json.load(tracker_file) 70 | old_tr = copy.deepcopy(tracker) 71 | for package in tracker: 72 | _LOGGER.info('Updating version for %s', package) 73 | local_path = tracker[package]['local_location'].lstrip('/\\') 74 | tracker[package]['version'] = \ 75 | get_component_version(local_path, package) 76 | base_path = os.path.split(local_path)[0] 77 | base_url = os.path.split(tracker[package]['remote_location'])[0] 78 | resources = [] 79 | for current_path, _, files in os.walk(base_path): 80 | if current_path.find('__pycache__') != -1: 81 | continue 82 | for file in files: 83 | file = os.path.join(current_path, file).replace('\\', '/') 84 | if file != local_path: 85 | resources.append(base_url + file[len(base_path):]) 86 | resources.sort() 87 | tracker[package]['resources'] = resources 88 | 89 | if tracker != old_tr: 90 | with open(tracker_fpath, 'w') as tracker_file: 91 | json.dump(tracker, tracker_file, indent=4) 92 | 93 | 94 | update_tracker(TRACKER_FPATH) 95 | # subprocess.run(["git", "add", TRACKER_FPATH]) 96 | -------------------------------------------------------------------------------- /custom_components/linkplay/media_player.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0511,C0412 2 | """ 3 | Support for LinkPlay based devices. 4 | 5 | For more details about this platform, please refer to the documentation at 6 | https://home-assistant.io/components/media_player.linkplay/ 7 | """ 8 | 9 | import binascii 10 | import json 11 | import upnpclient 12 | import netdisco.ssdp 13 | import logging 14 | import os 15 | import tempfile 16 | import urllib.request 17 | import xml.etree.ElementTree as ET 18 | 19 | import homeassistant.helpers.config_validation as cv 20 | import requests 21 | import voluptuous as vol 22 | from homeassistant.components.media_player import (MediaPlayerDevice) 23 | from homeassistant.components.media_player.const import ( 24 | DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, 25 | SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, 26 | SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, 27 | SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP) 28 | from homeassistant.const import ( 29 | ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, STATE_PAUSED, STATE_PLAYING, 30 | STATE_UNKNOWN) 31 | from homeassistant.util.dt import utcnow 32 | 33 | from . import VERSION, ISSUE_URL, DATA_LINKPLAY 34 | 35 | _LOGGER = logging.getLogger(__name__) 36 | 37 | ATTR_MASTER = 'master_id' 38 | ATTR_PRESET = 'preset' 39 | ATTR_SLAVES = 'slave_ids' 40 | 41 | CONF_DEVICE_NAME = 'device_name' 42 | CONF_LASTFM_API_KEY = 'lastfm_api_key' 43 | # 44 | CONF_DEVICENAME_DEPRECATED = 'devicename' # TODO: Remove this deprecated key in version 3.0 45 | 46 | DEFAULT_NAME = 'LinkPlay device' 47 | 48 | LASTFM_API_BASE = "http://ws.audioscrobbler.com/2.0/?method=" 49 | 50 | LINKPLAY_CONNECT_MULTIROOM_SCHEMA = vol.Schema({ 51 | vol.Required(ATTR_ENTITY_ID): cv.entity_id, 52 | vol.Required(ATTR_MASTER): cv.entity_id 53 | }) 54 | LINKPLAY_PRESET_BUTTON_SCHEMA = vol.Schema({ 55 | vol.Required(ATTR_ENTITY_ID): cv.entity_ids, 56 | vol.Required(ATTR_PRESET): cv.positive_int 57 | }) 58 | LINKPLAY_REMOVE_SLAVES_SCHEMA = vol.Schema({ 59 | vol.Required(ATTR_ENTITY_ID): cv.entity_id, 60 | vol.Required(ATTR_SLAVES): cv.entity_ids 61 | }) 62 | 63 | MAX_VOL = 100 64 | 65 | 66 | def check_device_name_keys(conf): # TODO: Remove this check in version 3.0 67 | """Ensure CONF_DEVICE_NAME or CONF_DEVICENAME_DEPRECATED are provided.""" 68 | if sum(param in conf for param in 69 | [CONF_DEVICE_NAME, CONF_DEVICENAME_DEPRECATED]) != 1: 70 | raise vol.Invalid(CONF_DEVICE_NAME + ' key not provided') 71 | # if CONF_DEVICENAME_DEPRECATED in conf: # TODO: Uncomment block in version 2.0 72 | # _LOGGER.warning("Key %s is deprecated. Please replace it with key %s", 73 | # CONF_DEVICENAME_DEPRECATED, CONF_DEVICE_NAME) 74 | return conf 75 | 76 | 77 | PLATFORM_SCHEMA = vol.All(cv.PLATFORM_SCHEMA.extend({ 78 | vol.Required(CONF_HOST): cv.string, 79 | vol.Optional(CONF_DEVICE_NAME): cv.string, # TODO: Mark required in version 3.0 80 | vol.Optional(CONF_NAME): cv.string, 81 | vol.Optional(CONF_LASTFM_API_KEY): cv.string, 82 | # 83 | vol.Optional(CONF_DEVICENAME_DEPRECATED): cv.string 84 | }), check_device_name_keys) 85 | 86 | SERVICE_CONNECT_MULTIROOM = 'linkplay_connect_multiroom' 87 | SERVICE_PRESET_BUTTON = 'linkplay_preset_button' 88 | SERVICE_REMOVE_SLAVES = 'linkplay_remove_slaves' 89 | 90 | SERVICE_TO_METHOD = { 91 | SERVICE_CONNECT_MULTIROOM: { 92 | 'method': 'connect_multiroom', 93 | 'schema': LINKPLAY_CONNECT_MULTIROOM_SCHEMA}, 94 | SERVICE_PRESET_BUTTON: { 95 | 'method': 'preset_button', 96 | 'schema': LINKPLAY_PRESET_BUTTON_SCHEMA}, 97 | SERVICE_REMOVE_SLAVES: { 98 | 'method': 'remove_slaves', 99 | 'schema': LINKPLAY_REMOVE_SLAVES_SCHEMA} 100 | } 101 | 102 | SUPPORT_LINKPLAY = \ 103 | SUPPORT_SELECT_SOURCE | SUPPORT_SELECT_SOUND_MODE | SUPPORT_SHUFFLE_SET | \ 104 | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ 105 | SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_PLAY | \ 106 | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | SUPPORT_SEEK | SUPPORT_PLAY_MEDIA 107 | 108 | SOUND_MODES = {'0': 'Normal', '1': 'Classic', '2': 'Pop', '3': 'Jazz', 109 | '4': 'Vocal'} 110 | SOURCES = {'wifi': 'WiFi', 'line-in': 'Line-in', 'bluetooth': 'Bluetooth', 111 | 'optical': 'Optical', 'udisk': 'MicroSD'} 112 | SOURCES_MAP = {'0': 'WiFi', '10': 'WiFi', '31': 'WiFi', '40': 'Line-in', 113 | '41': 'Bluetooth', '43': 'Optical'} 114 | UPNP_TIMEOUT = 5 115 | 116 | 117 | # pylint: disable=W0613 118 | def setup_platform(hass, config, add_entities, discovery_info=None): 119 | """Set up the LinkPlay device.""" 120 | # Print startup message 121 | _LOGGER.debug('Version %s', VERSION) 122 | _LOGGER.info('If you have any issues with this you need to open an issue ' 123 | 'here: %s', ISSUE_URL) 124 | 125 | if DATA_LINKPLAY not in hass.data: 126 | hass.data[DATA_LINKPLAY] = {} 127 | 128 | def _service_handler(service): 129 | """Map services to method of Linkplay devices.""" 130 | method = SERVICE_TO_METHOD.get(service.service) 131 | if not method: 132 | return 133 | 134 | params = {key: value for key, value in service.data.items() 135 | if key != ATTR_ENTITY_ID} 136 | entity_ids = service.data.get(ATTR_ENTITY_ID) 137 | if entity_ids: 138 | target_players = [player for player in 139 | hass.data[DATA_LINKPLAY].values() 140 | if player.entity_id in entity_ids] 141 | else: 142 | target_players = None 143 | 144 | for player in target_players: 145 | getattr(player, method['method'])(**params) 146 | 147 | for service in SERVICE_TO_METHOD: 148 | schema = SERVICE_TO_METHOD[service]['schema'] 149 | hass.services.register( 150 | DOMAIN, service, _service_handler, schema=schema) 151 | 152 | dev_name = config.get(CONF_DEVICE_NAME, 153 | config.get(CONF_DEVICENAME_DEPRECATED)) 154 | linkplay = LinkPlayDevice(config.get(CONF_HOST), 155 | dev_name, 156 | config.get(CONF_NAME), 157 | config.get(CONF_LASTFM_API_KEY)) 158 | 159 | add_entities([linkplay]) 160 | hass.data[DATA_LINKPLAY][dev_name] = linkplay 161 | 162 | 163 | # pylint: disable=R0902,R0904 164 | class LinkPlayDevice(MediaPlayerDevice): 165 | """Representation of a LinkPlay device.""" 166 | 167 | def __init__(self, host, devicename, name=None, lfm_api_key=None): 168 | """Initialize the LinkPlay device.""" 169 | self._devicename = devicename 170 | if name is not None: 171 | self._name = name 172 | else: 173 | self._name = self._devicename 174 | self._host = host 175 | self._state = STATE_UNKNOWN 176 | self._volume = 0 177 | self._source = None 178 | self._source_list = SOURCES.copy() 179 | self._sound_mode = None 180 | self._muted = False 181 | self._seek_position = 0 182 | self._duration = 0 183 | self._position_updated_at = None 184 | self._shuffle = False 185 | self._media_album = None 186 | self._media_artist = None 187 | self._media_title = None 188 | self._lpapi = LinkPlayRestData(self._host) 189 | self._media_image_url = None 190 | self._media_uri = None 191 | self._first_update = True 192 | if lfm_api_key is not None: 193 | self._lfmapi = LastFMRestData(lfm_api_key) 194 | else: 195 | self._lfmapi = None 196 | self._upnp_device = None 197 | self._slave_mode = False 198 | self._slave_ip = None 199 | self._master = None 200 | self._wifi_channel = None 201 | self._ssid = None 202 | self._playing_spotify = None 203 | self._slave_list = None 204 | self._new_song = True 205 | 206 | @property 207 | def name(self): 208 | """Return the name of the device.""" 209 | return self._name 210 | 211 | @property 212 | def state(self): 213 | """Return the state of the device.""" 214 | return self._state 215 | 216 | @property 217 | def volume_level(self): 218 | """Volume level of the media player (0..1).""" 219 | return int(self._volume) / MAX_VOL 220 | 221 | @property 222 | def is_volume_muted(self): 223 | """Return boolean if volume is currently muted.""" 224 | return bool(int(self._muted)) 225 | 226 | @property 227 | def source(self): 228 | """Return the current input source.""" 229 | return self._source 230 | 231 | @property 232 | def source_list(self): 233 | """Return the list of available input sources.""" 234 | return sorted(list(self._source_list.values())) 235 | 236 | @property 237 | def sound_mode(self): 238 | """Return the current sound mode.""" 239 | return self._sound_mode 240 | 241 | @property 242 | def sound_mode_list(self): 243 | """Return the available sound modes.""" 244 | return sorted(list(SOUND_MODES.values())) 245 | 246 | @property 247 | def supported_features(self): 248 | """Flag media player features that are supported.""" 249 | return SUPPORT_LINKPLAY 250 | 251 | @property 252 | def media_position(self): 253 | """Time in seconds of current seek position.""" 254 | return self._seek_position 255 | 256 | @property 257 | def media_duration(self): 258 | """Time in seconds of current song duration.""" 259 | return self._duration 260 | 261 | @property 262 | def media_position_updated_at(self): 263 | """When the seek position was last updated.""" 264 | return self._position_updated_at 265 | 266 | @property 267 | def shuffle(self): 268 | """Return True if shuffle mode is enabled.""" 269 | return self._shuffle 270 | 271 | @property 272 | def media_title(self): 273 | """Return title of the current track.""" 274 | return self._media_title 275 | 276 | @property 277 | def media_artist(self): 278 | """Return name of the current track artist.""" 279 | return self._media_artist 280 | 281 | @property 282 | def media_album_name(self): 283 | """Return name of the current track album.""" 284 | return self._media_album 285 | 286 | @property 287 | def media_image_url(self): 288 | """Return name the image for the current track.""" 289 | return self._media_image_url 290 | 291 | @property 292 | def media_content_type(self): 293 | """Content type of current playing media.""" 294 | return MEDIA_TYPE_MUSIC 295 | 296 | @property 297 | def ssid(self): 298 | """SSID to use for multiroom configuration.""" 299 | return self._ssid 300 | 301 | @property 302 | def wifi_channel(self): 303 | """Wifi channel to use for multiroom configuration.""" 304 | return self._wifi_channel 305 | 306 | @property 307 | def slave_ip(self): 308 | """Ip used in multiroom configuration.""" 309 | return self._slave_ip 310 | 311 | @property 312 | def lpapi(self): 313 | """Device API.""" 314 | return self._lpapi 315 | 316 | def turn_on(self): 317 | """Turn the media player on.""" 318 | _LOGGER.warning("This device cannot be turned on remotely.") 319 | 320 | def turn_off(self): 321 | """Turn off media player.""" 322 | self._lpapi.call('GET', 'setShutdown:0') 323 | value = self._lpapi.data 324 | if value != "OK": 325 | _LOGGER.warning("Failed to power off the device. Got response: %s", 326 | value) 327 | 328 | def set_volume_level(self, volume): 329 | """Set volume level, range 0..1.""" 330 | volume = str(round(volume * MAX_VOL)) 331 | if not self._slave_mode: 332 | self._lpapi.call('GET', 'setPlayerCmd:vol:{0}'.format(str(volume))) 333 | value = self._lpapi.data 334 | if value == "OK": 335 | self._volume = volume 336 | else: 337 | _LOGGER.warning("Failed to set volume. Got response: %s", 338 | value) 339 | else: 340 | self._master.lpapi.call('GET', 341 | 'multiroom:SlaveVolume:{0}:{1}'.format( 342 | self._slave_ip, str(volume))) 343 | value = self._master.lpapi.data 344 | if value == "OK": 345 | self._volume = volume 346 | else: 347 | _LOGGER.warning("Failed to set volume. Got response: %s", 348 | value) 349 | 350 | def mute_volume(self, mute): 351 | """Mute (true) or unmute (false) media player.""" 352 | if not self._slave_mode: 353 | self._lpapi.call('GET', 354 | 'setPlayerCmd:mute:{0}'.format(str(int(mute)))) 355 | value = self._lpapi.data 356 | if value == "OK": 357 | self._muted = mute 358 | else: 359 | _LOGGER.warning("Failed mute/unmute volume. Got response: %s", 360 | value) 361 | else: 362 | self._master.lpapi.call('GET', 363 | 'multiroom:SlaveMute:{0}:{1}'.format( 364 | self._slave_ip, str(int(mute)))) 365 | value = self._master.lpapi.data 366 | if value == "OK": 367 | self._muted = mute 368 | else: 369 | _LOGGER.warning("Failed mute/unmute volume. Got response: %s", 370 | value) 371 | 372 | def media_play(self): 373 | """Send play command.""" 374 | if not self._slave_mode: 375 | self._lpapi.call('GET', 'setPlayerCmd:play') 376 | value = self._lpapi.data 377 | if value == "OK": 378 | self._state = STATE_PLAYING 379 | for slave in self._slave_list: 380 | slave.set_state(STATE_PLAYING) 381 | else: 382 | _LOGGER.warning("Failed to start playback. Got response: %s", 383 | value) 384 | else: 385 | self._master.media_play() 386 | 387 | def media_pause(self): 388 | """Send pause command.""" 389 | if not self._slave_mode: 390 | self._lpapi.call('GET', 'setPlayerCmd:pause') 391 | value = self._lpapi.data 392 | if value == "OK": 393 | self._state = STATE_PAUSED 394 | for slave in self._slave_list: 395 | slave.set_state(STATE_PAUSED) 396 | else: 397 | _LOGGER.warning("Failed to pause playback. Got response: %s", 398 | value) 399 | else: 400 | self._master.media_pause() 401 | 402 | def media_stop(self): 403 | """Send stop command.""" 404 | self.media_pause() 405 | 406 | def media_next_track(self): 407 | """Send next track command.""" 408 | if not self._slave_mode: 409 | self._lpapi.call('GET', 'setPlayerCmd:next') 410 | value = self._lpapi.data 411 | if value != "OK": 412 | _LOGGER.warning("Failed skip to next track. Got response: %s", 413 | value) 414 | else: 415 | self._master.media_next_track() 416 | 417 | def media_previous_track(self): 418 | """Send previous track command.""" 419 | if not self._slave_mode: 420 | self._lpapi.call('GET', 'setPlayerCmd:prev') 421 | value = self._lpapi.data 422 | if value != "OK": 423 | _LOGGER.warning("Failed to skip to previous track." 424 | " Got response: %s", value) 425 | else: 426 | self._master.media_previous_track() 427 | 428 | def media_seek(self, position): 429 | """Send media_seek command to media player.""" 430 | if not self._slave_mode: 431 | self._lpapi.call('GET', 432 | 'setPlayerCmd:seek:{0}'.format(str(position))) 433 | value = self._lpapi.data 434 | if value != "OK": 435 | _LOGGER.warning("Failed to seek. Got response: %s", 436 | value) 437 | else: 438 | self._master.media_seek(position) 439 | 440 | def clear_playlist(self): 441 | """Clear players playlist.""" 442 | pass 443 | 444 | def play_media(self, media_type, media_id, **kwargs): 445 | """Play media from a URL or file.""" 446 | if not self._slave_mode: 447 | if not media_type == MEDIA_TYPE_MUSIC: 448 | _LOGGER.error( 449 | "Invalid media type %s. Only %s is supported", 450 | media_type, MEDIA_TYPE_MUSIC) 451 | return 452 | self._lpapi.call('GET', 'setPlayerCmd:play:{0}'.format(media_id)) 453 | value = self._lpapi.data 454 | if value != "OK": 455 | _LOGGER.warning("Failed to play media. Got response: %s", 456 | value) 457 | else: 458 | self._master.play_media(media_type, media_id) 459 | 460 | def select_source(self, source): 461 | """Select input source.""" 462 | if not self._slave_mode: 463 | if source == 'MicroSD': 464 | temp_source = 'udisk' 465 | else: 466 | temp_source = source.lower() 467 | self._lpapi.call('GET', 468 | 'setPlayerCmd:switchmode:{0}'.format(temp_source)) 469 | value = self._lpapi.data 470 | if value == "OK": 471 | self._source = source 472 | for slave in self._slave_list: 473 | slave.set_source(source) 474 | else: 475 | _LOGGER.warning("Failed to select source. Got response: %s", 476 | value) 477 | else: 478 | self._master.select_source(source) 479 | 480 | def select_sound_mode(self, sound_mode): 481 | """Set Sound Mode for device.""" 482 | if not self._slave_mode: 483 | mode = list(SOUND_MODES.keys())[list( 484 | SOUND_MODES.values()).index(sound_mode)] 485 | self._lpapi.call('GET', 'setPlayerCmd:equalizer:{0}'.format(mode)) 486 | value = self._lpapi.data 487 | if value == "OK": 488 | self._sound_mode = sound_mode 489 | for slave in self._slave_list: 490 | slave.set_sound_mode(sound_mode) 491 | else: 492 | _LOGGER.warning("Failed to set sound mode. Got response: %s", 493 | value) 494 | else: 495 | self._master.select_sound_mode(sound_mode) 496 | 497 | def set_shuffle(self, shuffle): 498 | """Change the shuffle mode.""" 499 | if not self._slave_mode: 500 | mode = '2' if shuffle else '0' 501 | self._lpapi.call('GET', 'setPlayerCmd:loopmode:{0}'.format(mode)) 502 | value = self._lpapi.data 503 | if value != "OK": 504 | _LOGGER.warning("Failed to change shuffle mode. " 505 | "Got response: %s", value) 506 | else: 507 | self._master.set_shuffle(shuffle) 508 | 509 | def preset_button(self, preset): 510 | """Simulate pressing a physical preset button.""" 511 | if not self._slave_mode: 512 | self._lpapi.call('GET', 513 | 'IOSimuKeyIn:{0}'.format(str(preset).zfill(3))) 514 | value = self._lpapi.data 515 | if value != "OK": 516 | _LOGGER.warning("Failed to press preset button %s. " 517 | "Got response: %s", preset, value) 518 | else: 519 | self._master.preset_button(preset) 520 | 521 | def connect_multiroom(self, master_id): 522 | """Add selected slaves to multiroom configuration.""" 523 | for device in self.hass.data[DATA_LINKPLAY].values(): 524 | if device.entity_id == master_id: 525 | cmd = "ConnectMasterAp:ssid={0}:ch={1}:auth=OPEN:".format( 526 | device.ssid, device.wifi_channel) + \ 527 | "encry=NONE:pwd=:chext=0" 528 | self._lpapi.call('GET', cmd) 529 | value = self._lpapi.data 530 | if value == "OK": 531 | self._slave_mode = True 532 | self._master = device 533 | else: 534 | _LOGGER.warning("Failed to connect multiroom. " 535 | "Got response: %s", value) 536 | 537 | def remove_slaves(self, slave_ids): 538 | """Remove selected slaves from multiroom configuration.""" 539 | for slave_id in slave_ids: 540 | for device in self.hass.data[DATA_LINKPLAY].values(): 541 | if device.entity_id == slave_id: 542 | self._lpapi.call('GET', 543 | 'multiroom:SlaveKickout:{0}'.format( 544 | device.slave_ip)) 545 | value = self._lpapi.data 546 | if value == "OK": 547 | device.set_slave_mode(False) 548 | device.set_slave_ip(None) 549 | device.set_master(None) 550 | else: 551 | _LOGGER.warning("Failed to remove slave %s. " 552 | "Got response: %s", slave_id, value) 553 | 554 | def set_master(self, master): 555 | """Set master device for multiroom configuration.""" 556 | self._master = master 557 | 558 | def set_slave_mode(self, slave_mode): 559 | """Set current device as slave in a multiroom configuration.""" 560 | self._slave_mode = slave_mode 561 | 562 | def set_media_title(self, title): 563 | """Set the media title property.""" 564 | self._media_title = title 565 | 566 | def set_media_artist(self, artist): 567 | """Set the media artist property.""" 568 | self._media_artist = artist 569 | 570 | def set_volume(self, volume): 571 | """Set the volume property.""" 572 | self._volume = volume 573 | 574 | def set_muted(self, mute): 575 | """Set the muted property.""" 576 | self._muted = mute 577 | 578 | def set_state(self, state): 579 | """Set the state property.""" 580 | self._state = state 581 | 582 | def set_slave_ip(self, slave_ip): 583 | """Set the slave ip property.""" 584 | self._slave_ip = slave_ip 585 | 586 | def set_seek_position(self, position): 587 | """Set the seek position property.""" 588 | self._seek_position = position 589 | 590 | def set_duration(self, duration): 591 | """Set the duration property.""" 592 | self._duration = duration 593 | 594 | def set_position_updated_at(self, time): 595 | """Set the position updated at property.""" 596 | self._position_updated_at = time 597 | 598 | def set_source(self, source): 599 | """Set the source property.""" 600 | self._source = source 601 | 602 | def set_sound_mode(self, mode): 603 | """Set the sound mode property.""" 604 | self._sound_mode = mode 605 | 606 | def _is_playing_new_track(self, status): 607 | """Check if track is changed since last update.""" 608 | if int(int(status['totlen']) / 1000) != self._duration: 609 | return True 610 | if status['totlen'] == '0': 611 | # Special case when listening to radio 612 | try: 613 | return bool(bytes.fromhex( 614 | status['Title']).decode('utf-8') != self._media_title) 615 | except ValueError: 616 | return True 617 | return False 618 | 619 | def _update_via_upnp(self): 620 | """Update track info via UPNP.""" 621 | import validators 622 | 623 | self._media_title = None 624 | self._media_album = None 625 | self._media_image_url = None 626 | 627 | if self._upnp_device is None: 628 | return 629 | 630 | media_info = self._upnp_device.AVTransport.GetMediaInfo(InstanceID=0) 631 | media_info = media_info.get('CurrentURIMetaData') 632 | 633 | if media_info is None: 634 | return 635 | 636 | xml_tree = ET.fromstring(media_info) 637 | 638 | xml_path = "{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}item/" 639 | title_xml_path = "{http://purl.org/dc/elements/1.1/}title" 640 | artist_xml_path = "{urn:schemas-upnp-org:metadata-1-0/upnp/}artist" 641 | album_xml_path = "{urn:schemas-upnp-org:metadata-1-0/upnp/}album" 642 | image_xml_path = "{urn:schemas-upnp-org:metadata-1-0/upnp/}albumArtURI" 643 | 644 | self._media_title = \ 645 | xml_tree.find("{0}{1}".format(xml_path, title_xml_path)).text 646 | self._media_artist = \ 647 | xml_tree.find("{0}{1}".format(xml_path, artist_xml_path)).text 648 | self._media_album = \ 649 | xml_tree.find("{0}{1}".format(xml_path, album_xml_path)).text 650 | self._media_image_url = \ 651 | xml_tree.find("{0}{1}".format(xml_path, image_xml_path)).text 652 | 653 | if not validators.url(self._media_image_url): 654 | self._media_image_url = None 655 | 656 | def _update_from_id3(self): 657 | """Update track info with eyed3.""" 658 | import eyed3 659 | from urllib.error import URLError 660 | try: 661 | filename, _ = urllib.request.urlretrieve(self._media_uri) 662 | audiofile = eyed3.load(filename) 663 | self._media_title = audiofile.tag.title 664 | self._media_artist = audiofile.tag.artist 665 | self._media_album = audiofile.tag.album 666 | # Remove tempfile when done 667 | if filename.startswith(tempfile.gettempdir()): 668 | os.remove(filename) 669 | 670 | except (URLError, ValueError): 671 | self._media_title = None 672 | self._media_artist = None 673 | self._media_album = None 674 | 675 | def _get_lastfm_coverart(self): 676 | """Get cover art from last.fm.""" 677 | self._lfmapi.call('GET', 678 | 'track.getInfo', 679 | "artist={0}&track={1}".format( 680 | self._media_artist, 681 | self._media_title)) 682 | lfmdata = json.loads(self._lfmapi.data) 683 | try: 684 | self._media_image_url = \ 685 | lfmdata['track']['album']['image'][2]['#text'] 686 | except (ValueError, KeyError): 687 | self._media_image_url = None 688 | 689 | def upnp_discover(self, timeout=5): 690 | devices = {} 691 | for entry in netdisco.ssdp.scan(timeout): 692 | if entry.location in devices: 693 | continue 694 | try: 695 | devices[entry.location] = upnpclient.Device(entry.location) 696 | except Exception as exc: 697 | _LOGGER.debug('Error \'%s\' for %s', exc, entry.location) 698 | return list(devices.values()) 699 | 700 | # pylint: disable=R0912,R0915 701 | def update(self): 702 | """Get the latest player details from the device.""" 703 | 704 | if self._slave_mode: 705 | return True 706 | 707 | if self._upnp_device is None: 708 | for entry in self.upnp_discover(UPNP_TIMEOUT): 709 | if entry.friendly_name == \ 710 | self._devicename: 711 | self._upnp_device = upnpclient.Device(entry.location) 712 | break 713 | 714 | self._lpapi.call('GET', 'getPlayerStatus') 715 | player_api_result = self._lpapi.data 716 | 717 | if player_api_result is None: 718 | _LOGGER.warning('Unable to connect to device') 719 | self._media_title = 'Unable to connect to device' 720 | return True 721 | 722 | try: 723 | player_status = json.loads(player_api_result) 724 | except ValueError: 725 | _LOGGER.warning("REST result could not be parsed as JSON") 726 | _LOGGER.debug("Erroneous JSON: %s", player_api_result) 727 | player_status = None 728 | 729 | if isinstance(player_status, dict): 730 | self._lpapi.call('GET', 'getStatus') 731 | device_api_result = self._lpapi.data 732 | if device_api_result is None: 733 | _LOGGER.warning('Unable to connect to device') 734 | self._media_title = 'Unable to connect to device' 735 | return True 736 | 737 | try: 738 | device_status = json.loads(device_api_result) 739 | except ValueError: 740 | _LOGGER.warning("REST result could not be parsed as JSON") 741 | _LOGGER.debug("Erroneous JSON: %s", device_api_result) 742 | device_status = None 743 | 744 | if isinstance(device_status, dict): 745 | self._wifi_channel = device_status['WifiChannel'] 746 | self._ssid = \ 747 | binascii.hexlify(device_status['ssid'].encode('utf-8')) 748 | self._ssid = self._ssid.decode() 749 | 750 | # Update variables that changes during playback of a track. 751 | self._volume = player_status['vol'] 752 | self._muted = player_status['mute'] 753 | self._seek_position = int(int(player_status['curpos']) / 1000) 754 | self._position_updated_at = utcnow() 755 | try: 756 | self._media_uri = str(bytearray.fromhex( 757 | player_status['iuri']).decode()) 758 | except KeyError: 759 | self._media_uri = None 760 | self._state = { 761 | 'stop': STATE_PAUSED, 762 | 'play': STATE_PLAYING, 763 | 'pause': STATE_PAUSED, 764 | }.get(player_status['status'], STATE_UNKNOWN) 765 | self._source = SOURCES_MAP.get(player_status['mode'], 766 | 'WiFi') 767 | self._sound_mode = SOUND_MODES.get(player_status['eq']) 768 | self._shuffle = (player_status['loop'] == '2') 769 | self._playing_spotify = bool(player_status['mode'] == '31') 770 | 771 | self._new_song = self._is_playing_new_track(player_status) 772 | if self._playing_spotify or player_status['totlen'] == '0': 773 | self._update_via_upnp() 774 | 775 | elif self._media_uri is not None and self._new_song: 776 | self._update_from_id3() 777 | if self._lfmapi is not None and \ 778 | self._media_title is not None: 779 | self._get_lastfm_coverart() 780 | else: 781 | self._media_image_url = None 782 | 783 | self._duration = int(int(player_status['totlen']) / 1000) 784 | 785 | else: 786 | _LOGGER.warning("JSON result was not a dictionary") 787 | 788 | # Get multiroom slave information 789 | self._lpapi.call('GET', 'multiroom:getSlaveList') 790 | slave_list = self._lpapi.data 791 | 792 | try: 793 | slave_list = json.loads(slave_list) 794 | except ValueError: 795 | _LOGGER.warning("REST result could not be parsed as JSON") 796 | _LOGGER.debug("Erroneous JSON: %s", slave_list) 797 | slave_list = None 798 | 799 | self._slave_list = [] 800 | if isinstance(slave_list, dict): 801 | if int(slave_list['slaves']) > 0: 802 | for slave in slave_list['slave_list']: 803 | device = self.hass.data[DATA_LINKPLAY].get(slave['name']) 804 | if device: 805 | self._slave_list.append(device) 806 | device.set_master(self) 807 | device.set_slave_mode(True) 808 | device.set_media_title("Slave mode") 809 | device.set_media_artist(self.name) 810 | device.set_volume(slave['volume']) 811 | device.set_muted(slave['mute']) 812 | device.set_state(self.state) 813 | device.set_slave_ip(slave['ip']) 814 | device.set_seek_position(self.media_position) 815 | device.set_duration(self.media_duration) 816 | device.set_position_updated_at( 817 | self.media_position_updated_at) 818 | device.set_source(self._source) 819 | device.set_sound_mode(self._sound_mode) 820 | else: 821 | _LOGGER.warning("JSON result was not a dictionary") 822 | 823 | return True 824 | 825 | 826 | # pylint: disable=R0903 827 | class LinkPlayRestData: 828 | """Class for handling the data retrieval from the LinkPlay device.""" 829 | 830 | def __init__(self, host): 831 | """Initialize the data object.""" 832 | self.data = None 833 | self._request = None 834 | self._host = host 835 | 836 | def call(self, method, cmd): 837 | """Get the latest data from REST service.""" 838 | self.data = None 839 | self._request = None 840 | resource = "http://{0}/httpapi.asp?command={1}".format(self._host, cmd) 841 | self._request = requests.Request(method, resource).prepare() 842 | 843 | _LOGGER.debug("Updating from %s", self._request.url) 844 | try: 845 | with requests.Session() as sess: 846 | response = sess.send( 847 | self._request, timeout=2) 848 | self.data = response.text 849 | 850 | except requests.exceptions.RequestException as ex: 851 | _LOGGER.error("Error fetching data: %s from %s failed with %s", 852 | self._request, self._request.url, ex) 853 | self.data = None 854 | 855 | 856 | # pylint: disable=R0903 857 | class LastFMRestData: 858 | """Class for handling the data retrieval from the LinkPlay device.""" 859 | 860 | def __init__(self, api_key): 861 | """Initialize the data object.""" 862 | self.data = None 863 | self._request = None 864 | self._api_key = api_key 865 | 866 | def call(self, method, cmd, params): 867 | """Get the latest data from REST service.""" 868 | self.data = None 869 | self._request = None 870 | resource = "{0}{1}&{2}&api_key={3}&format=json".format( 871 | LASTFM_API_BASE, cmd, params, self._api_key) 872 | self._request = requests.Request(method, resource).prepare() 873 | _LOGGER.debug("Updating from %s", self._request.url) 874 | 875 | try: 876 | with requests.Session() as sess: 877 | response = sess.send( 878 | self._request, timeout=10) 879 | self.data = response.text 880 | 881 | except requests.exceptions.RequestException as ex: 882 | _LOGGER.error("Error fetching data: %s from %s failed with %s", 883 | self._request, self._request.url, ex) 884 | self.data = None 885 | --------------------------------------------------------------------------------