├── .github └── workflows │ ├── hacs.yaml │ └── hassfest.yaml ├── .gitignore ├── README.md ├── custom_components └── sonos_cloud │ ├── __init__.py │ ├── api.py │ ├── application_credentials.py │ ├── config_flow.py │ ├── const.py │ ├── manifest.json │ ├── media_player.py │ ├── strings.json │ └── translations │ ├── en.json │ └── pt-BR.json ├── hacs.json └── setup.cfg /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | - name: HACS Action 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sonos Cloud integration for [Home Assistant](https://www.home-assistant.io) 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration) 4 | 5 | The `sonos_cloud` integration uses the cloud-based [Sonos Control API](https://docs.sonos.com/docs/control) to send [audioClip](https://docs.sonos.com/reference/audioclip-loadaudioclip-playerid) commands to speakers. This allows playback of short clips (e.g., alert sounds, TTS messages) on Sonos speakers without interrupting playback. Audio played in this manner will reduce the volume of currently playing music, play the clip on top of the music, and then automatically return the music to its original volume. This is an alternative approach to the current method which requires taking snapshots & restoring speakers with complex scripts and automations. 6 | 7 | This API requires audio files to be in `.mp3` or `.wav` format and to have publicly accessible URLs. 8 | 9 | # Installation 10 | **Easy**: Use [HACS](https://hacs.xyz) and add the Sonos Cloud Integration. 11 | 12 | **Manual**: Place all files from the `sonos_cloud` directory inside your `/custom_components/sonos_cloud/` directory. 13 | 14 | Both methods will require a restart of Home Assistant before you can configure the integration further. 15 | 16 | You will need to create an account on the [Sonos Developer site](https://developer.sonos.com), and then [create a new Control Integration](https://integration.sonos.com/integrations). Provide a display name and description, provide a Key Name, and save the integration. It is not necessary to set a Redirect URI or callback URL. Save the Key and Secret values for the integration configuration. 17 | 18 | # Configuration 19 |
20 | ** Click here if using Home Assistant 2022.5 or below ** 21 |
22 | Older versions of Home Assistant do not support setting application credentials in the frontend. 23 | 24 | Add an entry to your `configuration.yaml` using the Key and Secret from your Sonos app: 25 | ```yaml 26 | sonos_cloud: 27 | client_id: 28 | client_secret: 29 | ``` 30 | You will need to restart Home Assistant if adding credentials while already running. 31 | 32 | **Note**: This is no longer necessary in 2022.6 and later with Sonos Cloud release 0.3.0. 33 |
34 |
35 | 36 | [![Open your Home Assistant instance and start setting up sonos_cloud.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=sonos_cloud) 37 | 38 | On the Integrations page in Home Assistant, add a new "Sonos Cloud" integration. You will need to first provide your application credentials obtained from the Sonos Developer site above. The Key should be used as the `Client ID`, and the Secret as the `Client Secret`. 39 | 40 | You will then be redirected to the Sonos website to login with your "normal" Sonos username and password (_not_ your Sonos Developer login). You will receive a prompt saying "Allow to control your Sonos system". Accept this and the integration will complete configuration. 41 | 42 | # Usage 43 | 44 | The integration will create new `media_player` entities for each Sonos device in your household. You must reference these new `media_player` entities in order to use the `tts._say` and `media_player.play_media` services to play the clips. 45 | 46 | ## Media Browser & Media Source 47 | 48 | Support for browsing and playing back local audio clips using the Media Browser is supported. [Media Source](https://www.home-assistant.io/integrations/media_source/) URLs for local media and TTS can also be provided to `media_player.play_media`. 49 | 50 | ## Volume control 51 | 52 | The playback volume can be set per audio clip and will automatically revert to the previous level when the clip finishes playing. The volume used is chosen in the following order: 53 | 1. Use `data`->`extra`->`volume` if provided in the `media_player.play_media` call. 54 | 2. Use the volume on the `media_player` entity created by this integration. This default can be disabled by setting the volume slider back to 0. Note that this volume slider _only_ affects the default audio clip playback volume. 55 | 3. If neither of the above is provided, the current volume set on the speaker will be used. 56 | 57 | **Note**: Volume adjustments only work with the `media_player.play_media` service call. For TTS volume control, use `media_player.play_media` with a [Media Source](https://www.home-assistant.io/integrations/media_source/) TTS URL (see below). 58 | 59 | # Examples 60 | 61 | Service calls to `media_player.play_media` can accept optional parameters under `data`->`extra`: 62 | * `volume` will play the clip at a different volume than the currently playing music 63 | * `play_on_bonded` will play on all _bonded_ speakers in a "room" (see [notes](#home-theater--stereo-pair-configurations) below) 64 | ```yaml 65 | service: media_player.play_media 66 | data: 67 | entity_id: media_player.kitchen 68 | media_content_id: https://.ui.nabu.casa/local/sound_files/doorbell.mp3 69 | media_content_type: music 70 | extra: 71 | volume: 35 # Can be provided as 0-100 or 0.0-0.99 72 | play_on_bonded: true 73 | ``` 74 | 75 | [Media Source](https://www.home-assistant.io/integrations/media_source/) URLs are supported: 76 | ```yaml 77 | service: media_player.play_media 78 | data: 79 | entity_id: media_player.kitchen 80 | media_content_id: media-source://media_source/local/doorbell.mp3 81 | media_content_type: music 82 | ``` 83 | 84 | TTS volume controls can be used with a Media Source TTS URL: 85 | ```yaml 86 | service: media_player.play_media 87 | data: 88 | entity_id: media_player.kitchen 89 | media_content_id: media-source://tts/cloud?message=I am very loud 90 | media_content_type: music 91 | extra: 92 | volume: 80 93 | ``` 94 | 95 | "Standard" TTS service calls can also be used, but the extra parameters cannot be used: 96 | ```yaml 97 | service: tts.cloud_say 98 | data: 99 | entity_id: media_player.front_room 100 | message: "Hello there" 101 | ``` 102 | 103 | A special `media_content_id` of "CHIME" can be used to test the integration using the built-in sound provided by Sonos. This can be useful for validation if your own URLs are not playing correctly: 104 | ```yaml 105 | service: media_player.play_media 106 | data: 107 | entity_id: media_player.kitchen 108 | media_content_id: CHIME 109 | media_content_type: music 110 | ``` 111 | 112 | # Limitations 113 | 114 | If you encounter issues playing audio when using this integration, it may be related to one of the following reasons. 115 | 116 | ## Use the new Sonos entities 117 | 118 | If audio playback does not resume after playing a sound, you may have selected the incorrect entity. The integration will create new `media_player` entities for each Sonos device in your household. You must select these new entities as the target for playback, not the original entities. 119 | 120 | ## Device support 121 | 122 | Some device models may not support the `audioClip` feature or will only provide limited support. For example, some older models on S1 firmware may not support this feature at all. A warning message will be logged during startup for each unsupported device. Other speakers (e.g., Play:1 speakers) may support a stripped-down version of the feature which does not overlay the alert audio on top of playing music, but instead will pause/resume the background audio. 123 | 124 | ## Home theater & stereo pair configurations 125 | 126 | A stereo pair will only play back audio on the left speaker and a home theater setup will play from the "primary" speaker. This is because of a limitation in the API which can only target a single speaker device at a time. 127 | 128 | When using the `play_on_bonded` extra key, the integration will attempt to play the audio on all bonded speakers in a "room" by making multiple simultaneous calls. Since playback may not be perfectly synchronized with this method it is not enabled by default. 129 | 130 | ## Media URLs 131 | 132 | If serving files from your Home Assistant instance (e.g., from the `/www/` config directory or via TTS integrations), the URLs must be resolvable and directly reachable from the Sonos speakers. 133 | 134 | ### TTS 135 | 136 | To configure TTS integrations to use external URLs, set the `base_url` configuration option. 137 | 138 | Examples: 139 | ```yaml 140 | tts: 141 | - platform: cloud 142 | base_url: 'https://.ui.nabu.casa' 143 | language: en-US 144 | gender: female 145 | ``` 146 | or 147 | ```yaml 148 | tts: 149 | - platform: google_translate 150 | base_url: 'https://xxxxxx.duckdns.org:8123' 151 | ``` 152 | 153 | ## Secure connections 154 | 155 | Sonos devices have strict security requirements if served media over an SSL/TLS connection. [See more details here](https://docs.sonos.com/docs/security). 156 | -------------------------------------------------------------------------------- /custom_components/sonos_cloud/__init__.py: -------------------------------------------------------------------------------- 1 | """The Sonos Cloud integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | import voluptuous as vol 8 | 9 | from homeassistant.components.application_credentials import ( 10 | ClientCredential, 11 | async_import_client_credential, 12 | ) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.exceptions import ConfigEntryNotReady 17 | from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv 18 | 19 | from .const import DOMAIN, PLAYERS, SESSION 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | CONFIG_SCHEMA = vol.Schema( 24 | { 25 | DOMAIN: vol.Schema( 26 | { 27 | vol.Required(CONF_CLIENT_ID): cv.string, 28 | vol.Required(CONF_CLIENT_SECRET): cv.string, 29 | } 30 | ) 31 | }, 32 | extra=vol.ALLOW_EXTRA, 33 | ) 34 | 35 | PLATFORMS = ["media_player"] 36 | 37 | 38 | async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: 39 | """Set up the Sonos Cloud component.""" 40 | hass.data[DOMAIN] = {} 41 | 42 | if DOMAIN not in config: 43 | return True 44 | 45 | await async_import_client_credential( 46 | hass, 47 | DOMAIN, 48 | ClientCredential( 49 | config[DOMAIN][CONF_CLIENT_ID], 50 | config[DOMAIN][CONF_CLIENT_SECRET], 51 | ), 52 | ) 53 | 54 | _LOGGER.warning( 55 | "Application Credentials have been imported and can be removed from configuration.yaml" 56 | ) 57 | 58 | return True 59 | 60 | 61 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 62 | """Set up Sonos Cloud from a config entry.""" 63 | hass.data[DOMAIN][PLAYERS] = [] 64 | 65 | implementation = ( 66 | await config_entry_oauth2_flow.async_get_config_entry_implementation( 67 | hass, entry 68 | ) 69 | ) 70 | 71 | session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) 72 | hass.data[DOMAIN][SESSION] = session 73 | 74 | url = "https://api.ws.sonos.com/control/api/v1/households" 75 | try: 76 | result = await session.async_request("get", url) 77 | except OSError as exc: 78 | _LOGGER.error("Connection error requesting households: %s", exc) 79 | raise ConfigEntryNotReady from exc 80 | 81 | if result.status >= 400: 82 | body = await result.text() 83 | _LOGGER.error( 84 | "Household request failed (%s): %s", 85 | result.status, 86 | body, 87 | ) 88 | raise ConfigEntryNotReady 89 | 90 | json = await result.json() 91 | households = json.get("households") 92 | 93 | async def async_get_available_players(household: str) -> list[dict]: 94 | url = f"https://api.ws.sonos.com/control/api/v1/households/{household}/groups" 95 | try: 96 | result = await session.async_request("get", url) 97 | except OSError as exc: 98 | _LOGGER.error("Connection error requesting players: %s", exc) 99 | raise ConfigEntryNotReady from exc 100 | if result.status >= 400: 101 | body = await result.text() 102 | _LOGGER.error( 103 | "Requesting devices failed (%s): %s", 104 | result.status, 105 | body, 106 | ) 107 | return [] 108 | 109 | json = await result.json() 110 | _LOGGER.debug("Result: %s", json) 111 | all_players = json["players"] 112 | available_players = [] 113 | 114 | for player in all_players: 115 | if "AUDIO_CLIP" in player["capabilities"]: 116 | available_players.append(player) 117 | else: 118 | _LOGGER.warning( 119 | "%s (%s) does not support AUDIO_CLIP", player["name"], player["id"] 120 | ) 121 | 122 | return available_players 123 | 124 | for household in households: 125 | household_id = household["id"] 126 | if players := await async_get_available_players(household_id): 127 | _LOGGER.debug( 128 | "Adding players for household %s: %s", 129 | household_id, 130 | [player["name"] for player in players], 131 | ) 132 | hass.data[DOMAIN][PLAYERS].extend(players) 133 | 134 | if not hass.data[DOMAIN][PLAYERS]: 135 | _LOGGER.error("No players returned from household(s): %s", households) 136 | raise ConfigEntryNotReady 137 | 138 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 139 | 140 | return True 141 | 142 | 143 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 144 | """Unload a config entry.""" 145 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 146 | if unload_ok: 147 | hass.data[DOMAIN].pop(PLAYERS) 148 | hass.data[DOMAIN].pop(SESSION) 149 | 150 | return unload_ok 151 | -------------------------------------------------------------------------------- /custom_components/sonos_cloud/api.py: -------------------------------------------------------------------------------- 1 | """API for Sonos Cloud bound to Home Assistant OAuth.""" 2 | from base64 import b64encode 3 | import logging 4 | from typing import cast 5 | 6 | from homeassistant.components.application_credentials import AuthImplementation 7 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class CustomHeadersLocalOAuth2Implementation(AuthImplementation): 13 | """Subclass which overrides token requests to add custom headers.""" 14 | 15 | async def _token_request(self, data: dict) -> dict: 16 | """Make a token request.""" 17 | session = async_get_clientsession(self.hass) 18 | headers = {"Content-Type": "application/x-www-form-urlencoded;charset=utf-8"} 19 | secret = f"{self.client_id}:{self.client_secret}".encode() 20 | b64_encoded_secret = b64encode(secret).decode("utf-8") 21 | headers["Authorization"] = f"Basic {b64_encoded_secret}" 22 | resp = await session.post(self.token_url, data=data, headers=headers) 23 | if resp.status >= 400 and _LOGGER.isEnabledFor(logging.DEBUG): 24 | body = await resp.text() 25 | _LOGGER.debug( 26 | "Token request failed with status=%s, body=%s", 27 | resp.status, 28 | body, 29 | ) 30 | resp.raise_for_status() 31 | return cast(dict, await resp.json()) 32 | -------------------------------------------------------------------------------- /custom_components/sonos_cloud/application_credentials.py: -------------------------------------------------------------------------------- 1 | """Application credentials platform for sonos_cloud.""" 2 | from homeassistant.components.application_credentials import ( 3 | AuthorizationServer, 4 | ClientCredential, 5 | ) 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers import config_entry_oauth2_flow 8 | 9 | from .api import CustomHeadersLocalOAuth2Implementation 10 | 11 | 12 | async def async_get_auth_implementation( 13 | hass: HomeAssistant, auth_domain: str, credential: ClientCredential 14 | ) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: 15 | """Return sonos_cloud auth implementation.""" 16 | return CustomHeadersLocalOAuth2Implementation( 17 | hass, 18 | auth_domain, 19 | credential, 20 | AuthorizationServer( 21 | authorize_url="https://api.sonos.com/login/v3/oauth", 22 | token_url="https://api.sonos.com/login/v3/oauth/access", 23 | ), 24 | ) 25 | -------------------------------------------------------------------------------- /custom_components/sonos_cloud/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Sonos Cloud.""" 2 | import logging 3 | 4 | from homeassistant.helpers import config_entry_oauth2_flow 5 | 6 | from .const import DOMAIN 7 | 8 | DEFAULT_SCOPE = "playback-control-all" 9 | 10 | 11 | class OAuth2FlowHandler( 12 | config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN 13 | ): 14 | """Config flow to handle Sonos Cloud OAuth2 authentication.""" 15 | 16 | DOMAIN = DOMAIN 17 | 18 | @property 19 | def logger(self) -> logging.Logger: 20 | """Return logger.""" 21 | return logging.getLogger(__name__) 22 | 23 | @property 24 | def extra_authorize_data(self) -> dict: 25 | """Extra data that needs to be appended to the authorize url.""" 26 | return {"scope": DEFAULT_SCOPE} 27 | 28 | async def async_step_user(self, user_input=None): 29 | """Handle a flow start.""" 30 | await self.async_set_unique_id(DOMAIN) 31 | 32 | if self._async_current_entries(): 33 | return self.async_abort(reason="already_configured") 34 | 35 | return await super().async_step_user(user_input) 36 | -------------------------------------------------------------------------------- /custom_components/sonos_cloud/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Sonos Cloud integration.""" 2 | 3 | DOMAIN = "sonos_cloud" 4 | 5 | PLAYERS = "players" 6 | SESSION = "session" 7 | -------------------------------------------------------------------------------- /custom_components/sonos_cloud/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "sonos_cloud", 3 | "name": "Sonos Cloud", 4 | "after_dependencies": ["media_source", "sonos"], 5 | "codeowners": ["@jjlawren"], 6 | "config_flow": true, 7 | "dependencies": ["application_credentials"], 8 | "documentation": "https://github.com/jjlawren/sonos_cloud/", 9 | "homekit": {}, 10 | "iot_class": "cloud_polling", 11 | "issue_tracker": "https://github.com/jjlawren/sonos_cloud/issues", 12 | "requirements": [], 13 | "ssdp": [], 14 | "version": "0.3.5", 15 | "zeroconf": [] 16 | } 17 | -------------------------------------------------------------------------------- /custom_components/sonos_cloud/media_player.py: -------------------------------------------------------------------------------- 1 | """Support to interface with Sonos players.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import logging 6 | from typing import Any 7 | 8 | from homeassistant.components import media_source 9 | from homeassistant.components.media_player import ( 10 | BrowseMedia, 11 | MediaPlayerEntity, 12 | MediaPlayerEntityFeature, 13 | async_process_play_media_url, 14 | ) 15 | from homeassistant.components.media_player.const import ( 16 | ATTR_MEDIA_EXTRA, 17 | MEDIA_CLASS_DIRECTORY, 18 | ) 19 | from homeassistant.components.media_player.errors import BrowseError 20 | from homeassistant.components.sonos.const import DOMAIN as SONOS_DOMAIN 21 | from homeassistant.config_entries import ConfigEntry 22 | from homeassistant.const import STATE_IDLE 23 | from homeassistant.core import HomeAssistant 24 | from homeassistant.exceptions import HomeAssistantError 25 | from homeassistant.helpers.entity import DeviceInfo 26 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 27 | from homeassistant.helpers.restore_state import RestoreEntity 28 | 29 | from .const import DOMAIN, PLAYERS, SESSION 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | ATTR_VOLUME = "volume" 34 | 35 | AUDIO_CLIP_URI = "https://api.ws.sonos.com/control/api/v1/players/{device}/audioClip" 36 | 37 | 38 | async def async_setup_entry( 39 | hass: HomeAssistant, 40 | config_entry: ConfigEntry, 41 | async_add_entities: AddEntitiesCallback, 42 | ) -> None: 43 | """Set up Sonos cloud from a config entry.""" 44 | async_add_entities( 45 | [SonosCloudMediaPlayerEntity(player) for player in hass.data[DOMAIN][PLAYERS]] 46 | ) 47 | 48 | 49 | class SonosCloudMediaPlayerEntity(MediaPlayerEntity, RestoreEntity): 50 | """Representation of a Sonos Cloud entity.""" 51 | 52 | _attr_supported_features = ( 53 | MediaPlayerEntityFeature.BROWSE_MEDIA 54 | | MediaPlayerEntityFeature.PLAY_MEDIA 55 | | MediaPlayerEntityFeature.VOLUME_SET 56 | ) 57 | 58 | def __init__(self, player: dict[str, Any]): 59 | """Initializle the entity.""" 60 | self._attr_name = player["name"] 61 | self._attr_unique_id = player["id"] 62 | self._attr_volume_level = 0 63 | self.zone_devices = player["deviceIds"] 64 | 65 | async def async_added_to_hass(self): 66 | """Complete entity setup.""" 67 | await super().async_added_to_hass() 68 | await self.async_restore_states() 69 | 70 | async def async_restore_states(self) -> None: 71 | """Restore last entity state.""" 72 | if (last_state := await self.async_get_last_state()) is None: 73 | return 74 | 75 | if volume := last_state.attributes.get("volume_level"): 76 | self._attr_volume_level = volume 77 | 78 | @property 79 | def state(self) -> str: 80 | """Return the state of the entity.""" 81 | return STATE_IDLE 82 | 83 | @property 84 | def device_info(self) -> DeviceInfo: 85 | """Return information about the device.""" 86 | return DeviceInfo( 87 | identifiers={(SONOS_DOMAIN, self.unique_id)}, 88 | manufacturer="Sonos", 89 | name=self.name, 90 | ) 91 | 92 | async def async_set_volume_level(self, volume: float) -> None: 93 | """Set the volume level.""" 94 | self._attr_volume_level = volume 95 | 96 | async def async_play_media( 97 | self, media_type: str, media_id: str, **kwargs: Any 98 | ) -> None: 99 | """ 100 | Send the play_media command to the media player. 101 | 102 | Used to play audio clips over the currently playing music. 103 | """ 104 | if media_source.is_media_source_id(media_id): 105 | media_source_item = await media_source.async_resolve_media( 106 | self.hass, media_id, self.entity_id 107 | ) 108 | media_id = async_process_play_media_url(self.hass, media_source_item.url) 109 | 110 | data = { 111 | "name": "HA Audio Clip", 112 | "appId": "jjlawren.home-assistant.sonos_cloud", 113 | } 114 | devices = [self.unique_id] 115 | 116 | if extra := kwargs.get(ATTR_MEDIA_EXTRA): 117 | if extra.get("play_on_bonded"): 118 | devices = self.zone_devices 119 | if volume := extra.get(ATTR_VOLUME): 120 | if type(volume) not in (int, float): 121 | raise HomeAssistantError(f"Volume '{volume}' not a number") 122 | if not 0 < volume <= 100: 123 | raise HomeAssistantError( 124 | f"Volume '{volume}' not in acceptable range of 0-100" 125 | ) 126 | if volume < 1: 127 | volume = volume * 100 128 | data[ATTR_VOLUME] = int(volume) 129 | 130 | if ATTR_VOLUME not in data and self.volume_level: 131 | data[ATTR_VOLUME] = int(self.volume_level * 100) 132 | 133 | if media_id != "CHIME": 134 | data["streamUrl"] = media_id 135 | 136 | session = self.hass.data[DOMAIN][SESSION] 137 | requests = [] 138 | 139 | for device in devices: 140 | url = AUDIO_CLIP_URI.format(device=device) 141 | _LOGGER.debug("Playing on %s (%s): %s", self.name, device, data) 142 | requests.append(session.async_request("post", url, json=data)) 143 | results = await asyncio.gather(*requests, return_exceptions=True) 144 | for result in results: 145 | if result.status >= 400: 146 | body = await result.text() 147 | _LOGGER.error( 148 | "Play request failed (%s): %s", 149 | result.status, 150 | body, 151 | ) 152 | continue 153 | 154 | json = await result.json() 155 | _LOGGER.debug("Response for %s: %s", result.url, json) 156 | 157 | async def async_browse_media( 158 | self, media_content_type: str | None = None, media_content_id: str | None = None 159 | ) -> Any: 160 | """Implement the websocket media browsing helper.""" 161 | if media_content_id is None: 162 | return await root_payload(self.hass) 163 | 164 | if media_source.is_media_source_id(media_content_id): 165 | return await media_source.async_browse_media( 166 | self.hass, media_content_id, content_filter=media_source_filter 167 | ) 168 | 169 | raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}") 170 | 171 | 172 | def media_source_filter(item: BrowseMedia): 173 | """Filter media sources.""" 174 | return item.media_content_type.startswith("audio/") 175 | 176 | 177 | async def root_payload( 178 | hass: HomeAssistant, 179 | ): 180 | """Return root payload for Sonos Cloud.""" 181 | children = [] 182 | 183 | try: 184 | item = await media_source.async_browse_media( 185 | hass, None, content_filter=media_source_filter 186 | ) 187 | # If domain is None, it's overview of available sources 188 | if item.domain is None: 189 | children.extend(item.children) 190 | else: 191 | children.append(item) 192 | except media_source.BrowseError: 193 | pass 194 | 195 | return BrowseMedia( 196 | title="Sonos Cloud", 197 | media_class=MEDIA_CLASS_DIRECTORY, 198 | media_content_id="", 199 | media_content_type="root", 200 | can_play=False, 201 | can_expand=True, 202 | children=children, 203 | ) 204 | -------------------------------------------------------------------------------- /custom_components/sonos_cloud/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "pick_implementation": { 5 | "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" 6 | } 7 | }, 8 | "abort": { 9 | "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", 10 | "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", 11 | "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", 12 | "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", 13 | "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", 14 | "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" 15 | }, 16 | "create_entry": { 17 | "default": "[%key:common::config_flow::create_entry::authenticated%]" 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /custom_components/sonos_cloud/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Account is already configured", 5 | "already_in_progress": "Configuration flow is already in progress", 6 | "authorize_url_timeout": "Timeout generating authorize URL.", 7 | "missing_configuration": "The component is not configured. Please follow the documentation.", 8 | "no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", 9 | "oauth_error": "Received invalid token data." 10 | }, 11 | "create_entry": { 12 | "default": "Successfully authenticated" 13 | }, 14 | "step": { 15 | "pick_implementation": { 16 | "title": "Pick Authentication Method" 17 | } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /custom_components/sonos_cloud/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "A conta já está configurada", 5 | "already_in_progress": "A configuração já está em andamento", 6 | "authorize_url_timeout": "Tempo limite gerando URL de autorização.", 7 | "missing_configuration": "O componente não está configurado. Por favor, siga a documentação.", 8 | "no_url_available": "Nenhuma URL disponível. Para obter informações sobre este erro, [verifique a seção de ajuda]({docs_url})", 9 | "oauth_error": "Dados de token inválidos recebidos." 10 | }, 11 | "create_entry": { 12 | "default": "Autenticado com sucesso" 13 | }, 14 | "step": { 15 | "pick_implementation": { 16 | "title": "Escolha o método de autenticação" 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sonos Cloud", 3 | "country": "US", 4 | "homeassistant": "2022.8.0", 5 | "render_readme": true 6 | } 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | max-complexity = 25 4 | doctests = True 5 | # To work with Black 6 | # E501: line too long 7 | # W503: Line break occurred before a binary operator 8 | # E203: Whitespace before ':' 9 | # D202 No blank lines allowed after function docstring 10 | # W504 line break after binary operator 11 | ignore = 12 | E501, 13 | W503, 14 | E203, 15 | D202, 16 | W504 17 | noqa-require-code = True 18 | --------------------------------------------------------------------------------