├── .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 | 
4 |
5 | 
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 |
18 |
19 |
or support via Bitcoin or Etherium:
20 | 
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 |
--------------------------------------------------------------------------------