diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..44767d3 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,18 @@ +name: Tests +on: + push: + pull_request: +permissions: + contents: read +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.14' + cache: pip + - run: pip install -r requirements_test.txt + - run: ruff check custom_components tests + - run: pytest -q diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 921e613..b900f48 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -12,7 +12,7 @@ jobs: hacs: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v2" + - uses: "actions/checkout@v7" - name: HACS validation uses: "hacs/action@main" with: @@ -23,7 +23,7 @@ jobs: name: Hassfest steps: - name: Check out the repository - uses: "actions/checkout@v2.3.4" + uses: "actions/checkout@v7" - name: Hassfest validation - uses: "home-assistant/actions/hassfest@master" \ No newline at end of file + uses: "home-assistant/actions/hassfest@master" diff --git a/.ruff.toml b/.ruff.toml index 7a8331a..56fe720 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,48 +1,7 @@ -# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml +target-version = "py312" -target-version = "py310" +[lint] +select = ["E4", "E7", "E9", "F", "I"] -select = [ - "B007", # Loop control variable {name} not used within loop body - "B014", # Exception handler with duplicate exception - "C", # complexity - "D", # docstrings - "E", # pycodestyle - "F", # pyflakes/autoflake - "ICN001", # import concentions; {name} should be imported as {asname} - "PGH004", # Use specific rule codes when using noqa - "PLC0414", # Useless import alias. Import alias does not rename original package. - "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass - "SIM117", # Merge with-statements that use the same scope - "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() - "SIM201", # Use {left} != {right} instead of not {left} == {right} - "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} - "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. - "SIM401", # Use get from dict with default instead of an if block - "T20", # flake8-print - "TRY004", # Prefer TypeError exception for invalid type - "RUF006", # Store a reference to the return value of asyncio.create_task - "UP", # pyupgrade - "W", # pycodestyle -] - -ignore = [ - "D202", # No blank lines allowed after function docstring - "D203", # 1 blank line required before class docstring - "D213", # Multi-line docstring summary should start at the second line - "D404", # First word of the docstring should not be This - "D406", # Section name should end with a newline - "D407", # Section name underlining - "D411", # Missing blank line before section - "E501", # line too long - "E731", # do not assign a lambda expression, use a def -] - -[flake8-pytest-style] -fixture-parentheses = false - -[pyupgrade] -keep-runtime-typing = true - -[mccabe] -max-complexity = 25 \ No newline at end of file +[lint.per-file-ignores] +"tests/test_integration.py" = ["E402"] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6ec6baf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 0.2.0 + +- Replace inherited network methods with one validated, bounded HTTP transport. +- Remove the legacy pycasatunes dependency; use local REST data models and HA's shared aiohttp session. +- Preserve zone unique IDs and native search, TTS and doorbell services. +- Use typed runtime data and current Home Assistant service/discovery interfaces. +- Publish complete poll snapshots and cache system/source metadata. +- Correct shuffle routes, buffering states and playback position timestamps. +- Add repeat, relative volume and standard queue mappings. +- Respect enabled sources and source/zone capabilities. +- Isolate groups to one server and report members from every grouped entity. +- Handle removed zones and discover new zones during polling. +- Add address reconfiguration, redacted diagnostics and power-command debug logs. +- Add API and Home Assistant regression tests and CI. + +Requires Home Assistant 2026.9+. Restart Home Assistant after installing. +This release does not claim to fix server-originated idle shutdowns. diff --git a/README.md b/README.md index 91a9bb5..34798f6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,66 @@ Your CasaTunes unit should be discovered automatically. If this doesn't happen, - Work around CasaTunes REST API payload shape differences that can otherwise crash polling. - Improve media browsing, now-playing data, artwork proxying, grouping, search, TTS, and doorbell behavior. +## Version 0.2.0 + +Requires Home Assistant 2026.9 or newer. Existing zone unique IDs and the +`casatunes.search`, `casatunes.tts`, and `casatunes.doorbell` actions are preserved. +Restart Home Assistant after updating Python files. + +The integration uses local HTTP on port 8735. CasaTunes deliberately supports +control through GET requests. All requests have a ten-second timeout, validate +HTTP and application-level errors, and release their connections. No automatic +retry is made for commands that might already have executed. + +Zone and playback data are polled every 15 seconds; system and source metadata +are cached for five minutes. Failed polls do not publish partially updated data. +Entities become unavailable when communication fails or a zone disappears. +New zones are discovered during polling. Hidden zones are disabled by default +when first created. + +Supported controls depend on the selected source and zone settings. Shuffle, +repeat, buffering states, relative volume, enabled sources and group membership +follow the server API. Groups must belong to the same CasaTunes server. +Playback position timestamps represent received data, rather than property reads. + +Browse Media provides CasaTunes media IDs. Generic URLs, Home Assistant media +source URLs and the standard URL announcement option are not supported. +Queue options `add`, `play`, and `replace` are supported; `next` is rejected +because the documented CasaTunes queue API has no equivalent. Native TTS and +doorbell actions remain available. + +To change the server address, use **Settings → Devices & services → CasaTunes → +Reconfigure**. The new address must identify the same server. +To remove the integration, delete its entry in Devices & services; this does +not alter CasaTunes server settings. + +## Troubleshooting and diagnostics + +Download diagnostics from the integration entry. They include power, sleep, +source IDs and group status, but omit IP/MAC addresses, room names and media titles. +Enable debug logging for `custom_components.casatunes` to record power-command +intent. The integration does not implement a shutdown timer or a keep-on guard. +A keep-on automation is a separate, user-controlled workaround, not a server fix. + +If a zone switches off, compare CasaTunes zone status with Home Assistant traces +and command logs. A missing HA logbook context does not establish who sent a +power-off request. Caller attribution may require server logs or network tracing. + +## Development + +Use Python 3.14 on Linux with Home Assistant 2026.9.1: + +```sh +pip install -r requirements_test.txt +ruff check custom_components tests +pytest -q +``` + +The API contract tests also run without Home Assistant. CI additionally exercises +real Home Assistant setup, unload, discovery, failures, entity state and services. +See the server's `http://SERVER/casadev/` documentation and +`http://SERVER:8735/api/v1/resources` for its installed API definitions. + ## Known CasaTunes API issue Some CasaTunes servers can return duplicate rows for custom Internet Stations and Favorites from the CasaTunes API itself. This integration does not hide those duplicates with a Home Assistant-side filter, so the underlying CasaTunes data/cache issue remains visible and can be fixed at the source. diff --git a/custom_components/casatunes/__init__.py b/custom_components/casatunes/__init__.py index 1100b23..d928a62 100644 --- a/custom_components/casatunes/__init__.py +++ b/custom_components/casatunes/__init__.py @@ -1,156 +1,54 @@ """The CasaTunes integration.""" -from __future__ import annotations -from datetime import timedelta -import logging - -from aiohttp import ClientError -from pycasatunes.exceptions import CasaException -from pycasatunes.objects.system import CasaTunesSystem -from pycasatunes.objects.zone import CasaTunesZone -import voluptuous as vol - -from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.const import CONF_HOST from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import service from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) + from .api import CasaTunesClient from .const import DOMAIN +from .coordinator import CasaTunesDataUpdateCoordinator -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -PLATFORMS = [MEDIA_PLAYER_DOMAIN] -_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=15) -UPDATE_ERRORS = (CasaException, ClientError, TimeoutError) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +PLATFORMS = [Platform.MEDIA_PLAYER] +CasaTunesConfigEntry = ConfigEntry[CasaTunesDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up CasaTunes from a config entry.""" +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Register actions even when the server is unavailable.""" + from .media_player import DOORBELL_SCHEMA, SEARCH_SCHEMA, TTS_SCHEMA - client = CasaTunesClient(async_get_clientsession(hass), entry.data[CONF_HOST]) - coordinator = CasaTunesDataUpdateCoordinator(hass, client=client) + for name, schema, method in ( + ("search", SEARCH_SCHEMA, "search"), + ("tts", TTS_SCHEMA, "async_tts"), + ("doorbell", DOORBELL_SCHEMA, "async_doorbell"), + ): + service.async_register_platform_entity_service( + hass, + DOMAIN, + name, + entity_domain=Platform.MEDIA_PLAYER, + schema=schema, + func=method, + ) + return True - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator - # Fetch initial data so we have data when entities subscribe +async def async_setup_entry(hass: HomeAssistant, entry: CasaTunesConfigEntry) -> bool: + """Connect before creating entities.""" + coordinator = CasaTunesDataUpdateCoordinator( + hass, + entry, + CasaTunesClient(async_get_clientsession(hass), entry.data[CONF_HOST]), + ) await coordinator.async_config_entry_first_refresh() - + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id, None) - - return unload_ok - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) - - -class CasaTunesDataUpdateCoordinator(DataUpdateCoordinator[CasaTunesClient]): - """Class to manage fetching data from the API.""" - - def __init__(self, hass: HomeAssistant, client: CasaTunesClient) -> None: - """Initialize.""" - self.casatunes = client - - super().__init__( - hass, - logger=_LOGGER, - name=DOMAIN, - update_method=self._async_update_data, - update_interval=SCAN_INTERVAL, - ) - self.entities: list[CasaTunesDeviceEntity] = [] - - async def _async_update_data(self) -> CasaTunesClient: - """Update data via library.""" - try: - await self.casatunes.fetch() - except UPDATE_ERRORS as exception: - raise UpdateFailed("Error communicating with CasaTunes") from exception - - return self.casatunes - - -class CasaTunesEntity(CoordinatorEntity): - """Defines a base CasaTunes entity.""" - - def __init__( - self, - coordinator: CasaTunesDataUpdateCoordinator, - zone: CasaTunesZone, - device_id: str, - zone_id: str, - ) -> None: - """Initialize the CasaTunes entity.""" - super().__init__(coordinator) - self._zone_id = zone_id - self._zone = zone - self._device_id = device_id - self._name = zone.Name - - @property - def zone_id(self) -> str: - """Return the zone_id of the entity.""" - return self._zone_id - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def system(self) -> CasaTunesSystem: - """Get the CasaTunes System.""" - return self.coordinator.data.system - - @property - def zone(self) -> CasaTunesZone: - """Get the CasaTunes Zones.""" - return self.coordinator.data.zones_dict[self._zone_id] - - -class CasaTunesDeviceEntity(CasaTunesEntity): - """Defines a CasaTunes device entity.""" - - @property - def device_info(self) -> DeviceInfo | None: - """Return device information about this CasaTunes device.""" - if not self._device_id: - return None - - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer="CasaTunes", - name=self._name, - sw_version=self.system.CasaTunesVersion, - ) +async def async_unload_entry(hass: HomeAssistant, entry: CasaTunesConfigEntry) -> bool: + """Unload through the config-entry lifecycle.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/casatunes/api.py b/custom_components/casatunes/api.py index 0c35321..72845a1 100644 --- a/custom_components/casatunes/api.py +++ b/custom_components/casatunes/api.py @@ -1,24 +1,140 @@ """Compatibility helpers for the CasaTunes API client.""" + from __future__ import annotations +import logging from collections.abc import Mapping +from dataclasses import dataclass +from time import monotonic from typing import Any, TypeVar from urllib.parse import quote, urlencode -from aiohttp import ClientResponse -from pycasatunes import CasaTunes -from pycasatunes.const import API_PORT -from pycasatunes.exceptions import CasaException -from pycasatunes.objects.nowplaying import CasaTunesNowPlaying -from pycasatunes.objects.source import CasaTunesSource -from pycasatunes.objects.zone import CasaTunesZone +from aiohttp import ClientError, ClientSession, ClientTimeout + +from .models import ( + CasaException, + CasaTunesNowPlaying, + CasaTunesSource, + CasaTunesSystem, + CasaTunesZone, +) _CasaTunesObjectT = TypeVar("_CasaTunesObjectT") -class CasaTunesClient(CasaTunes): +_LOGGER = logging.getLogger(__name__) +API_PORT = 8735 +REQUEST_TIMEOUT = ClientTimeout(total=10) + + +@dataclass(frozen=True) +class CasaTunesData: + """A complete poll; never publish partially updated collections.""" + + system: CasaTunesSystem + zones: list[CasaTunesZone] + zones_dict: dict + sources: list[CasaTunesSource] + sources_dict: dict + nowplaying_dict: dict + + +class CasaTunesClient: """CasaTunes client with defensive response parsing.""" + def __init__(self, client: ClientSession, host: str) -> None: + self._client = client + self._host = host + self._static_at = 0.0 + self.data: CasaTunesData | None = None + + async def fetch(self) -> CasaTunesData: + """Refresh live state, caching system/source metadata for five minutes.""" + old = self.data + refresh_static = old is None or monotonic() - self._static_at >= 300 + try: + zones_raw = await self._get_json("/api/v1/zones") + playing_raw = await self._get_json("/api/v1/sources/nowplaying") + if refresh_static: + system_raw = await self._get_json("/api/v1/system/info") + if not isinstance(system_raw, dict) or not system_raw.get("MACAddress"): + raise CasaException("System response missing MAC address") + system = CasaTunesSystem(self._client, system_raw) + sources_raw = await self._get_json("/api/v1/sources") + sources = [ + CasaTunesSource(self._client, x) + for x in self._normalize_collection( + sources_raw, "sources", "SourceID" + ) + ] + else: + system, sources = old.system, old.sources + zones = [ + CasaTunesZone(self._client, x) + for x in self._normalize_collection(zones_raw, "zones", "ZoneID") + ] + playing = [ + CasaTunesNowPlaying(self._client, x) + for x in self._normalize_collection( + playing_raw, "nowplaying", "SourceID" + ) + ] + data = CasaTunesData( + system, + zones, + self._index_by(zones, "ZoneID"), + sources, + self._index_by(sources, "SourceID"), + self._index_by(playing, "SourceID"), + ) + except (CasaException, ClientError, TimeoutError): + self._static_at = 0 + raise + self.data = data + if refresh_static: + self._static_at = monotonic() + return data + + async def set_zone(self, zone_id: int | str, **params: Any) -> Any: + """Set zone properties; log power intent without personal media data.""" + if "Power" in params: + _LOGGER.debug("Power command: zone=%s power=%s", zone_id, params["Power"]) + return await self._get_json( + f"/api/v1/zones/{quote(str(zone_id), safe='')}", params + ) + + async def turn_on(self, zone_id): + return await self.set_zone(zone_id, Power="on") + + async def turn_off(self, zone_id): + return await self.set_zone(zone_id, Power="off") + + async def set_volume_level(self, zone_id, volume): + return await self.set_zone(zone_id, Volume=volume) + + async def mute_volume(self, zone_id, mute): + return await self.set_zone(zone_id, Mute=mute) + + async def change_source(self, zone_id, source): + return await self.set_zone(zone_id, SourceID=source) + + async def zone_master(self, zone_id, mode): + return await self.set_zone(zone_id, MasterMode=mode) + + async def zone_join(self, zone_id, client_zone_id): + return await self._get_json(f"/api/v1/zones/{zone_id}/group/{client_zone_id}") + + async def zone_unjoin(self, zone_id, client_zone_id): + return await self._get_json(f"/api/v1/zones/{zone_id}/ungroup/{client_zone_id}") + + async def player_action(self, zone_id, action, option=None): + path = f"/api/v1/zones/{zone_id}/player/{quote(action.lower(), safe='')}" + if option is not None: + if isinstance(option, bool): + option = str(option).lower() + path += f"/{quote(str(option), safe='')}" + return await self._get_json(path) + @staticmethod def _clean_params(params: Mapping[str, Any] | None) -> dict[str, str]: """Return query params accepted by the CasaTunes API.""" @@ -43,17 +159,27 @@ async def _get_json( if query: path = f"{path}?{query}" - response: ClientResponse = await self._client.get( - f"http://{self._host}:{API_PORT}{path}" - ) - response.raise_for_status() try: - payload = await response.json() - except Exception as exception: + async with self._client.get( + f"http://{self._host}:{API_PORT}{path}", timeout=REQUEST_TIMEOUT + ) as response: + response.raise_for_status() + payload = await response.json() + except (ValueError, ClientError, TimeoutError) as exception: raise CasaException( - "CasaTunes returned an invalid JSON response" + f"CasaTunes request failed ({type(exception).__name__})" ) from exception - self.logger.debug(payload) + if isinstance(payload, dict): + error = payload.get("Error") + if error and ( + not isinstance(error, dict) + or error.get("Message") + or error.get("Symbol") + or (error.get("HttpStatus") or 0) >= 400 + ): + raise CasaException("CasaTunes rejected the requested operation") + if payload.get("Result") is False: + raise CasaException("CasaTunes reported an unsuccessful operation") return payload @staticmethod @@ -100,7 +226,27 @@ def _normalize_collection( if not all(isinstance(item, Mapping) for item in items): raise CasaException(f"Unexpected CasaTunes {collection_name} item type") - return [dict(item) for item in items] + result = [] + seen = set() + for item in items: + item = dict(item) + try: + if isinstance(item.get(id_key), bool): + raise ValueError + item[id_key] = int(item[id_key]) + except (KeyError, TypeError, ValueError) as err: + raise CasaException(f"Invalid {id_key}") from err + if item[id_key] in seen: + raise CasaException(f"Duplicate {id_key}") + seen.add(item[id_key]) + if collection_name == "zones" and not isinstance(item.get("Power"), bool): + raise CasaException("Zone response missing valid Power") + if collection_name == "nowplaying" and not isinstance( + item.get("CurrSong"), dict + ): + item["CurrSong"] = {} + result.append(item) + return result @staticmethod def _index_by( @@ -125,33 +271,6 @@ def image_url(self, image_id_or_url: str) -> str: return f"http://{self._host}:{API_PORT}{image}" return f"http://{self._host}:{API_PORT}/api/v1/images/{quote(image, safe='')}" - async def get_zones(self) -> None: - """Get zones.""" - payload = await self._get_json("/api/v1/zones") - self._zones = [ - CasaTunesZone(self._client, zone) - for zone in self._normalize_collection(payload, "zones", "ZoneID") - ] - self._zones_dict = self._index_by(self._zones, "ZoneID") - - async def get_sources(self) -> None: - """Get sources.""" - payload = await self._get_json("/api/v1/sources") - self._sources = [ - CasaTunesSource(self._client, source) - for source in self._normalize_collection(payload, "sources", "SourceID") - ] - self._sources_dict = self._index_by(self._sources, "SourceID") - - async def get_nowplaying(self) -> None: - """Get now playing information.""" - payload = await self._get_json("/api/v1/sources/nowplaying") - self._nowplaying = [ - CasaTunesNowPlaying(self._client, item) - for item in self._normalize_collection(payload, "nowplaying", "SourceID") - ] - self._nowplaying_dict = self._index_by(self._nowplaying, "SourceID") - async def get_media(self, opts: Mapping[str, Any]) -> dict[str, Any]: """Get media items for a zone or collection.""" if item_id := opts.get("item_id"): diff --git a/custom_components/casatunes/browse_media.py b/custom_components/casatunes/browse_media.py index 8a8468e..cc6200e 100644 --- a/custom_components/casatunes/browse_media.py +++ b/custom_components/casatunes/browse_media.py @@ -1,10 +1,13 @@ """Support for CasaTunes media browsing.""" -from typing import Any + import logging +from typing import Any from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType from homeassistant.components.media_player.errors import BrowseError +from .models import CasaException + class UnknownMediaType(BrowseError): """Unknown media type.""" @@ -28,7 +31,7 @@ async def build_item_response( return await library_payload(casa_server, zone_id, media_content_id) raise UnknownMediaType - except UnknownMediaType as err: + except (UnknownMediaType, CasaException) as err: raise BrowseError( f"Media not found: {media_content_type} / {media_content_id}" ) from err @@ -50,7 +53,7 @@ async def item_payload(casa_server, item): image_id = item.get("ArtworkURI") if image_id: image_id = str(image_id) - thumbnail = casa_server.data.image_url(image_id) + thumbnail = casa_server.client.image_url(image_id) flags = _media_flags(item) @@ -59,7 +62,7 @@ async def item_payload(casa_server, item): media_class = MediaClass.PLAYLIST can_play = True can_expand = True - elif (flags & CT_COLLECTION): + elif flags & CT_COLLECTION: media_content_type = "library" media_class = MediaClass.DIRECTORY can_play = False @@ -98,7 +101,9 @@ async def library_payload(casa_server, zone_id, media_content_id): opts["item_id"] = media_content_id content_id = media_content_id - result_detail = await casa_server.data.get_media(opts) + result_detail = await casa_server.client.get_media(opts) + if not isinstance(result_detail, dict): + raise BrowseError("Invalid CasaTunes media collection") _LOGGER.debug("Result detail %s", result_detail) list_title = result_detail.get("Title", "Browse Media") diff --git a/custom_components/casatunes/config_flow.py b/custom_components/casatunes/config_flow.py index ec8e928..5195ec2 100644 --- a/custom_components/casatunes/config_flow.py +++ b/custom_components/casatunes/config_flow.py @@ -1,19 +1,17 @@ """Config flow for CasaTunes.""" + from __future__ import annotations import asyncio import logging from urllib.parse import urlparse -from aiohttp import ClientError -from pycasatunes.exceptions import CasaException import voluptuous as vol - -from homeassistant.components.ssdp import ATTR_SSDP_LOCATION +from aiohttp import ClientError from homeassistant.config_entries import ConfigFlow -from homeassistant.data_entry_flow import FlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.ssdp import ( @@ -23,11 +21,12 @@ from .api import CasaTunesClient from .const import DOMAIN +from .models import CasaException _LOGGER = logging.getLogger(__name__) CONNECT_ERRORS = (CasaException, ClientError, TimeoutError) -STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): vol.All(str, str.strip)}) async def validate_input(hass: HomeAssistant, data: dict) -> dict: @@ -35,8 +34,8 @@ async def validate_input(hass: HomeAssistant, data: dict) -> dict: session = async_get_clientsession(hass) casa = CasaTunesClient(session, data[CONF_HOST]) async with asyncio.timeout(10): - await casa.fetch() - system = casa.system + snapshot = await casa.fetch() + system = snapshot.system if not system.MACAddress: raise CasaException("CasaTunes system response missing MAC address") return { @@ -87,21 +86,19 @@ async def async_step_user( data={CONF_HOST: user_input[CONF_HOST]}, ) - async def async_step_ssdp( - self, discovery_info: SsdpServiceInfo - ) -> FlowResult: + async def async_step_ssdp(self, discovery_info: SsdpServiceInfo) -> FlowResult: """Handle a flow initiated by SSDP discovery.""" - host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + host = urlparse(discovery_info.ssdp_location or "").hostname if host is None: return self.async_abort(reason="cannot_connect") - name = discovery_info[ATTR_UPNP_FRIENDLY_NAME] + name = discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, "CasaTunes") try: session = async_get_clientsession(self.hass) casa = CasaTunesClient(session, host) async with asyncio.timeout(10): - await casa.fetch() - mac = casa.system.MACAddress + snapshot = await casa.fetch() + mac = snapshot.system.MACAddress if not mac: raise CasaException("CasaTunes system response missing MAC address") except CONNECT_ERRORS: @@ -131,3 +128,26 @@ async def async_step_discovery_confirm( step_id="discovery_confirm", description_placeholders={"name": self.discovery_info[CONF_NAME]}, ) + + async def async_step_reconfigure(self, user_input=None): + """Change the address without replacing devices or entities.""" + errors = {} + entry = self._get_reconfigure_entry() + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CONNECT_ERRORS: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(info["mac_address"]) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + entry, data_updates=user_input + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, entry.data + ), + errors=errors, + ) diff --git a/custom_components/casatunes/const.py b/custom_components/casatunes/const.py index 08cf99c..0efe558 100644 --- a/custom_components/casatunes/const.py +++ b/custom_components/casatunes/const.py @@ -1,4 +1,5 @@ """Constants for the CasaTunes integration.""" + DOMAIN = "casatunes" # Attributes diff --git a/custom_components/casatunes/coordinator.py b/custom_components/casatunes/coordinator.py new file mode 100644 index 0000000..9f66745 --- /dev/null +++ b/custom_components/casatunes/coordinator.py @@ -0,0 +1,54 @@ +"""Shared polling and action error handling.""" + +import asyncio +import logging +from datetime import timedelta + +from aiohttp import ClientError +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import utcnow + +from .api import CasaTunesClient, CasaTunesData +from .const import DOMAIN +from .models import CasaException + +_LOGGER = logging.getLogger(__name__) + + +class CasaTunesDataUpdateCoordinator(DataUpdateCoordinator[CasaTunesData]): + """Publish complete snapshots and coalesce action refreshes.""" + + def __init__(self, hass, entry, client: CasaTunesClient): + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=timedelta(seconds=15), + ) + self.client = client + self.entities = [] + self.updated_at = None + self.command_lock = asyncio.Lock() + + async def _async_update_data(self): + try: + async with asyncio.timeout(35): + data = await self.client.fetch() + except (CasaException, ClientError, TimeoutError) as err: + raise UpdateFailed("Error communicating with CasaTunes") from err + self.updated_at = utcnow() + return data + + async def command(self, method, *args, **kwargs): + """Serialize commands; surface server errors to action callers.""" + try: + async with self.command_lock: + result = await getattr(self.client, method)(*args, **kwargs) + except (CasaException, ClientError, TimeoutError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="command_failed" + ) from err + await self.async_request_refresh() + return result diff --git a/custom_components/casatunes/diagnostics.py b/custom_components/casatunes/diagnostics.py new file mode 100644 index 0000000..d2d741a --- /dev/null +++ b/custom_components/casatunes/diagnostics.py @@ -0,0 +1,28 @@ +"""Redacted integration diagnostics, excluding host and personal media data.""" + + +async def async_get_config_entry_diagnostics(hass, entry): + """Return operational status without addresses or media titles.""" + coordinator = entry.runtime_data + data = coordinator.data + return { + "last_update_success": coordinator.last_update_success, + "server_version": data.system.CasaTunesVersion, + "rest_version": data.system.RESTServicesVersion, + "zones": [ + { + "id": z.ZoneID, + "power": z.Power, + "source_id": z.SourceID, + "sleep_enabled": z.SleepEnabled, + "dnd": z.DND, + "master": z.MasterMode, + "shared_room_id": z.SharedRoomID, + } + for z in data.zones + ], + "sources": [ + {"id": s.SourceID, "type": s.Type, "media_types": s.MediaTypesSupported} + for s in data.sources + ], + } diff --git a/custom_components/casatunes/entity.py b/custom_components/casatunes/entity.py new file mode 100644 index 0000000..a6d9abe --- /dev/null +++ b/custom_components/casatunes/entity.py @@ -0,0 +1,41 @@ +"""Base zone entity preserving existing registry identifiers.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +class CasaTunesDeviceEntity(CoordinatorEntity): + """A zone on one CasaTunes server.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, coordinator, zone, device_id, zone_id): + super().__init__(coordinator) + self._zone_id = zone_id + self._zone = zone + self._device_id = device_id + + @property + def zone_id(self): + return self._zone_id + + @property + def zone(self): + # Retain the last known object while the zone is unavailable. + return self.coordinator.data.zones_dict.get(self._zone_id, self._zone) + + @property + def available(self): + return super().available and self._zone_id in self.coordinator.data.zones_dict + + @property + def device_info(self): + return DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer="CasaTunes", + name=self.zone.Name, + sw_version=self.coordinator.data.system.CasaTunesVersion, + ) diff --git a/custom_components/casatunes/manifest.json b/custom_components/casatunes/manifest.json index 8e96178..cae31f9 100644 --- a/custom_components/casatunes/manifest.json +++ b/custom_components/casatunes/manifest.json @@ -1,21 +1,19 @@ { "domain": "casatunes", "name": "CasaTunes", + "codeowners": [ + "@alphasixtyfive" + ], "config_flow": true, "documentation": "https://github.com/alphasixtyfive/casatunes", + "iot_class": "local_polling", "issue_tracker": "https://github.com/alphasixtyfive/casatunes/issues", - "version": "0.1.10", - "requirements": [ - "pycasatunes==0.1.3" - ], + "requirements": [], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", "friendlyName": "CASASERVER" } ], - "codeowners": [ - "@alphasixtyfive" - ], - "iot_class": "local_polling" + "version": "0.2.0" } diff --git a/custom_components/casatunes/media_player.py b/custom_components/casatunes/media_player.py index cb40b95..24da510 100644 --- a/custom_components/casatunes/media_player.py +++ b/custom_components/casatunes/media_player.py @@ -1,26 +1,28 @@ """Support for the CasaTunes media player.""" + from __future__ import annotations -from datetime import datetime import logging +from datetime import datetime from typing import Any -import voluptuous as vol -from homeassistant.util.dt import utcnow -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +import voluptuous as vol from homeassistant.components.media_player import ( BrowseMedia, + MediaPlayerDeviceClass, + MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, - MediaPlayerDeviceClass, + RepeatMode, ) -from homeassistant.helpers import config_validation as cv, entity_platform - -from pycasatunes.objects.zone import CasaTunesZone +from homeassistant.core import callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .browse_media import CT_ALLOWSELECT, CT_COLLECTION, build_item_response from .const import ( ATTR_CHIME, ATTR_GENDER, @@ -33,16 +35,13 @@ ATTR_MODE, ATTR_POST_WAIT, ATTR_PRE_WAIT, - ATTR_VOLUME, ATTR_SSML, ATTR_VOICE, - DOMAIN, - SERVICE_DOORBELL, - SERVICE_SEARCH, - SERVICE_TTS, + ATTR_VOLUME, ) -from .browse_media import CT_ALLOWSELECT, CT_COLLECTION, build_item_response -from . import CasaTunesDataUpdateCoordinator, CasaTunesDeviceEntity +from .coordinator import CasaTunesDataUpdateCoordinator +from .entity import CasaTunesDeviceEntity +from .models import CasaTunesZone _LOGGER = logging.getLogger(__name__) @@ -181,60 +180,37 @@ def _best_search_item( return max(items, key=lambda item: _search_score(item, service_data)) + # map CasaTunes status codes to MediaPlayerState enums STATUS_TO_STATE = { 0: MediaPlayerState.IDLE, 1: MediaPlayerState.PAUSED, 2: MediaPlayerState.PLAYING, - 3: MediaPlayerState.ON, + 3: MediaPlayerState.BUFFERING, + 4: MediaPlayerState.BUFFERING, + 5: MediaPlayerState.BUFFERING, } -SUPPORT_CASATUNES = ( - MediaPlayerEntityFeature.BROWSE_MEDIA - | MediaPlayerEntityFeature.CLEAR_PLAYLIST - | MediaPlayerEntityFeature.GROUPING - | MediaPlayerEntityFeature.NEXT_TRACK - | MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.PREVIOUS_TRACK - | MediaPlayerEntityFeature.SEEK - | MediaPlayerEntityFeature.SELECT_SOURCE - | MediaPlayerEntityFeature.SHUFFLE_SET - | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.TURN_OFF - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.VOLUME_SET -) async def async_setup_entry(hass, entry, async_add_entities): """Set up the CasaTunes config entry.""" - coordinator: CasaTunesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: CasaTunesDataUpdateCoordinator = entry.runtime_data unique_id = coordinator.data.system.attributes["MACAddress"] - players = [ - CasaTunesMediaPlayer(coordinator, zone, unique_id) - for zone in coordinator.data.zones - ] - async_add_entities(players) + known_zones = set() - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_SEARCH, - SEARCH_SCHEMA, - "search", - ) - platform.async_register_entity_service( - SERVICE_TTS, - TTS_SCHEMA, - "async_tts", - ) - platform.async_register_entity_service( - SERVICE_DOORBELL, - DOORBELL_SCHEMA, - "async_doorbell", - ) + @callback + def add_new_zones(): + players = [] + for zone in coordinator.data.zones: + if zone.ZoneID not in known_zones: + known_zones.add(zone.ZoneID) + players.append(CasaTunesMediaPlayer(coordinator, zone, unique_id)) + if players: + async_add_entities(players) + + add_new_zones() + entry.async_on_unload(coordinator.async_add_listener(add_new_zones)) class CasaTunesMediaPlayer(CasaTunesDeviceEntity, MediaPlayerEntity): @@ -254,11 +230,9 @@ def __init__( zone_id=zone.ZoneID, ) self._attr_unique_id = f"{unique_id}_{zone.ZoneID}" - self._attr_supported_features = SUPPORT_CASATUNES self._attr_device_class = MediaPlayerDeviceClass.SPEAKER - self._server = coordinator self._zone_id = zone.ZoneID - self._media_position_updated_at = None + self._attr_entity_registry_enabled_default = not zone.Hidden async def async_added_to_hass(self): """Entity added to hass.""" @@ -286,13 +260,11 @@ def _media_playback_trackable(self) -> bool: def _casatunes_entities(self) -> list[CasaTunesMediaPlayer]: """Return all media player entities of the system.""" - entities: list[CasaTunesMediaPlayer] = [] - for coord in self.hass.data[DOMAIN].values(): - entities += [ - ent for ent in coord.entities - if isinstance(ent, CasaTunesMediaPlayer) - ] - return entities + return [ + ent + for ent in self.coordinator.entities + if isinstance(ent, CasaTunesMediaPlayer) + ] def _group_entities(self) -> list[CasaTunesMediaPlayer]: """Return entities in this zone's CasaTunes group.""" @@ -315,11 +287,6 @@ def is_client(self) -> bool: """Return True if this zone is a client.""" return bool(self.zone.SharedRoomID and not self.zone.MasterMode) - @property - def name(self) -> str: - """Return the name of the device.""" - return self.zone.GroupName or self.zone.Name - @property def state(self) -> MediaPlayerState | None: """Return the state of the device.""" @@ -332,6 +299,87 @@ def state(self) -> MediaPlayerState | None: return STATUS_TO_STATE.get(status, MediaPlayerState.ON) return MediaPlayerState.ON + def _source_enabled(self, source_id: int) -> bool: + mask = self.zone.EnabledSources + return mask is None or bool(int(mask) & (1 << int(source_id))) + + @property + def supported_features(self): + features = MediaPlayerEntityFeature.GROUPING + if not self.zone.HidePowerControl: + features |= ( + MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF + ) + if not self.zone.HideSourceControl: + features |= MediaPlayerEntityFeature.SELECT_SOURCE + if not self.zone.FixedVolumeEnabled: + features |= ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_STEP + ) + if self.zone.VolumeControlType != 2: + features |= MediaPlayerEntityFeature.VOLUME_SET + source = self.coordinator.data.sources_dict.get(self.zone.SourceID) + # Current servers put player/tuner/external classification in Type. + # Older CasaDev enum pages label that mask MediaTypesSupported. + player_type = ( + ( + (source.Type or 0) + if source.SourceType is not None + else (source.MediaTypesSupported or 0) + ) + if source + else 0 + ) + if not player_type & 1: + return features + features |= ( + MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.MEDIA_ENQUEUE + ) + controls = ( + (self._nowplaying.attributes.get("Controls") or 0) + if self._nowplaying + else 0 + ) + for mask, feature in ( + (1, MediaPlayerEntityFeature.PLAY), + (2, MediaPlayerEntityFeature.STOP), + (4, MediaPlayerEntityFeature.PAUSE), + (8, MediaPlayerEntityFeature.SHUFFLE_SET), + (16, MediaPlayerEntityFeature.REPEAT_SET), + (32, MediaPlayerEntityFeature.NEXT_TRACK), + (64, MediaPlayerEntityFeature.PREVIOUS_TRACK), + (256, MediaPlayerEntityFeature.SEEK), + (0x40000, MediaPlayerEntityFeature.CLEAR_PLAYLIST), + ): + if controls & mask: + features |= feature + return features + + @property + def repeat(self): + if self._nowplaying: + return {0: RepeatMode.OFF, 1: RepeatMode.ALL, 2: RepeatMode.ONE}.get( + self._nowplaying.RepeatMode + ) + return None + + async def async_set_repeat(self, repeat): + modes = {RepeatMode.OFF: "off", RepeatMode.ALL: "on", RepeatMode.ONE: "once"} + if repeat not in modes: + raise ServiceValidationError("Invalid repeat mode") + await self.coordinator.command( + "player_action", self.zone_id, "repeat", modes[repeat] + ) + + async def async_volume_up(self): + await self.coordinator.command("set_zone", self.zone_id, AdjustVolume=1) + + async def async_volume_down(self): + await self.coordinator.command("set_zone", self.zone_id, AdjustVolume=-1) + @property def shuffle(self) -> bool | None: """Boolean if shuffle is enabled.""" @@ -367,8 +415,9 @@ def source(self) -> str | None: def source_list(self) -> list[str]: """List of available input sources.""" return [ - src.Name for src in self.coordinator.data.sources - if not src.Hidden + src.Name + for src in self.coordinator.data.sources + if not src.Hidden and self._source_enabled(src.SourceID) ] @property @@ -376,7 +425,7 @@ def media_track(self) -> int | None: """Return the track number of current media.""" nowplaying = self._nowplaying if nowplaying is not None: - return nowplaying.QueueSongIndex + return None # Queue index is not an album track number. return None @property @@ -414,7 +463,6 @@ def media_duration(self) -> int | None: def media_position(self) -> int | None: """Position of current playing media in seconds.""" if self._media_playback_trackable(): - self._media_position_updated_at = utcnow() return self._nowplaying.CurrProgress return None @@ -422,7 +470,7 @@ def media_position(self) -> int | None: def media_position_updated_at(self) -> datetime | None: """When the position was last updated.""" if self._media_playback_trackable(): - return self._media_position_updated_at + return self.coordinator.updated_at return None @property @@ -436,7 +484,7 @@ def media_image_url(self) -> str | None: nowplaying = self._nowplaying if nowplaying is not None: if image := nowplaying.CurrSong.ArtworkURI: - return self.coordinator.data.image_url(image) + return self.coordinator.client.image_url(image) return None @property @@ -447,10 +495,12 @@ def media_image_remotely_accessible(self) -> bool: @property def group_members(self) -> list[str] | None: """Return a list of entity_ids in this zone's group.""" - if not self.is_master: - return None - clients = [ent.entity_id for ent in self._group_entities() if ent.is_client] - return [self.entity_id] + clients + group = self._group_entities() + if not group: + return [self.entity_id] + return [ + ent.entity_id for ent in sorted(group, key=lambda ent: not ent.is_master) + ] @property def zone_master(self) -> int | None: @@ -463,85 +513,97 @@ def zone_master(self) -> int | None: return z.ZoneID return None - async def sync_master(self): - """Ensure master status is correct after unjoin/join.""" - if not any(ent.is_client for ent in self._group_entities()): - master = self.zone_master - if master is not None: - await self.coordinator.data.zone_master(master, False) - await self.coordinator.async_refresh() - _LOGGER.debug("Zone %s is no longer master.", master) - async def async_turn_on(self): - await self.coordinator.data.turn_on(self.zone_id) - await self.coordinator.async_refresh() + await self.coordinator.command("turn_on", self.zone_id) async def async_turn_off(self): - await self.coordinator.data.turn_off(self.zone_id) - await self.coordinator.async_refresh() + await self.coordinator.command("turn_off", self.zone_id) async def async_set_volume_level(self, volume: float): - await self.coordinator.data.set_volume_level(self.zone_id, int(volume * 100)) - await self.coordinator.async_refresh() + await self.coordinator.command( + "set_volume_level", self.zone_id, round(volume * 100) + ) async def async_mute_volume(self, mute: bool): - await self.coordinator.data.mute_volume(self.zone_id, mute) - await self.coordinator.async_refresh() + await self.coordinator.command("mute_volume", self.zone_id, mute) async def async_media_seek(self, position: int): - await self.coordinator.data.player_action(self.zone_id, "Position", position) - self._media_position_updated_at = utcnow() - await self.coordinator.async_refresh() + await self.coordinator.command( + "player_action", self.zone_id, "Position", position + ) async def async_media_previous_track(self): - await self.coordinator.data.player_action(self.zone_id, "previous") - await self.coordinator.async_refresh() + await self.coordinator.command("player_action", self.zone_id, "previous") async def async_media_next_track(self): - await self.coordinator.data.player_action(self.zone_id, "next") - await self.coordinator.async_refresh() + await self.coordinator.command("player_action", self.zone_id, "next") async def async_media_play(self): - await self.coordinator.data.player_action(self.zone_id, "play") - await self.coordinator.async_refresh() + await self.coordinator.command("player_action", self.zone_id, "play") async def async_media_pause(self): - await self.coordinator.data.player_action(self.zone_id, "pause") - await self.coordinator.async_refresh() + await self.coordinator.command("player_action", self.zone_id, "pause") async def async_media_stop(self): - await self.coordinator.data.player_action(self.zone_id, "stop") - await self.coordinator.async_refresh() + await self.coordinator.command("player_action", self.zone_id, "stop") async def async_set_shuffle(self, shuffle: bool): - flag = f"ShuffleMode={'true' if shuffle else 'false'}" - await self.coordinator.data.player_action(self.zone_id, "shuffle", flag) - await self.coordinator.async_refresh() + flag = shuffle + await self.coordinator.command("player_action", self.zone_id, "shuffle", flag) async def async_select_source(self, source: str): for src in self.coordinator.data.sources: - if src.Name == source: - await self.coordinator.data.change_source(self.zone_id, src.SourceID) - await self.coordinator.async_refresh() - await self.sync_master() + if ( + src.Name == source + and not src.Hidden + and self._source_enabled(src.SourceID) + ): + await self.coordinator.command( + "change_source", self.zone_id, src.SourceID + ) return + raise ServiceValidationError(f"Source is not available in this zone: {source}") async def async_join_players(self, group_members: list[str]): """Join this player with others.""" - await self.coordinator.data.zone_master(self.zone_id, True) - for ent in self._casatunes_entities(): - if ent.entity_id in group_members and ent != self: - await self.coordinator.data.zone_join(self.zone_id, ent.zone_id) - await self.coordinator.async_refresh() - await self.sync_master() + entities = { + ent.entity_id: ent for ent in self._casatunes_entities() if ent.available + } + if any(member not in entities for member in group_members): + raise ServiceValidationError( + "All grouped players must belong to this CasaTunes server" + ) + members = [ + entities[member] for member in group_members if member != self.entity_id + ] + if not members: + return + await self.coordinator.command("zone_master", self.zone_id, True) + for ent in members: + await self.coordinator.command("zone_join", self.zone_id, ent.zone_id) async def async_unjoin_player(self): - """Remove this player from its group.""" + """Detach clients, or dissolve a group when its leader leaves.""" master = self.zone_master - if master is not None: - await self.coordinator.data.zone_unjoin(master, self.zone_id) - await self.coordinator.async_refresh() - await self.sync_master() + if master is None: + return + if self.is_master: + for ent in self._group_entities(): + if ent is not self: + await self.coordinator.command("zone_unjoin", master, ent.zone_id) + else: + await self.coordinator.command("zone_unjoin", master, self.zone_id) + # Group cleanup depends on the completed mutation, so bypass debounce here. + await self.coordinator.async_refresh() + if not self.coordinator.last_update_success: + return + leader = next( + (ent for ent in self._casatunes_entities() if ent.zone_id == master), None + ) + if leader is not None and not any( + ent.is_client for ent in leader._group_entities() + ): + await self.coordinator.command("zone_master", master, False) async def async_browse_media( self, @@ -558,33 +620,55 @@ async def async_browse_media( async def async_play_media(self, media_type, media_id, **kwargs): """Play the given media.""" - _LOGGER.debug("Playback request for %s / %s", media_type, media_id) - await self.coordinator.data.play_media(self.zone_id, media_id) - await self.coordinator.async_refresh() + if kwargs.get("announce"): + raise ServiceValidationError( + "URL announcements are not supported; use casatunes.tts" + ) + enqueue = kwargs.get("enqueue") + modes = { + None: None, + MediaPlayerEnqueue.REPLACE: "playNow", + MediaPlayerEnqueue.ADD: "add", + MediaPlayerEnqueue.PLAY: "addplay", + } + if enqueue not in modes: + raise ServiceValidationError( + "CasaTunes does not support inserting media next" + ) + if media_type not in ( + "library", + MediaType.TRACK, + MediaType.MUSIC, + MediaType.PLAYLIST, + ): + raise ServiceValidationError("Use a CasaTunes media ID from Browse Media") + await self.coordinator.command( + "play_media", self.zone_id, media_id, add_to_queue=modes[enqueue] + ) async def async_clear_playlist(self): """Clear the current playlist.""" - await self.coordinator.data.clear_zone_queue(self.zone_id) - await self.coordinator.async_refresh() + await self.coordinator.command("clear_zone_queue", self.zone_id) async def search(self, **service_data): """Search for media and play or queue the best match.""" search_text = _build_search_text(service_data) - result = await self.coordinator.data.search_media(self.zone_id, search_text) + result = await self.coordinator.command( + "search_media", self.zone_id, search_text + ) + if not isinstance(result, dict): + raise ServiceValidationError("Invalid CasaTunes search response") item = _best_search_item(result, service_data) if item is None: - raise HomeAssistantError(f"No CasaTunes media found for {search_text}") + raise ServiceValidationError(f"No CasaTunes media found for {search_text}") mode = service_data.get(ATTR_MODE, DEFAULT_QUEUE_MODE) - await self.coordinator.data.queue_media(self.zone_id, item["ID"], mode) - await self.coordinator.async_refresh() + await self.coordinator.command("queue_media", self.zone_id, item["ID"], mode) async def async_tts(self, **service_data): """Play text-to-speech in this zone.""" - await self.coordinator.data.tts(self.zone_id, service_data) - await self.coordinator.async_refresh() + await self.coordinator.command("tts", self.zone_id, service_data) async def async_doorbell(self, **service_data): """Play a doorbell chime in this zone.""" - await self.coordinator.data.doorbell(self.zone_id, service_data) - await self.coordinator.async_refresh() + await self.coordinator.command("doorbell", self.zone_id, service_data) diff --git a/custom_components/casatunes/models.py b/custom_components/casatunes/models.py new file mode 100644 index 0000000..80c17bb --- /dev/null +++ b/custom_components/casatunes/models.py @@ -0,0 +1,180 @@ +"""Small data models for the documented CasaTunes REST fields.""" + + +class CasaException(Exception): + """A transport or protocol failure.""" + + +class CasaTunesObject: + """Retain one validated JSON record without any network behavior.""" + + def __init__(self, client, attributes): + self.attributes = dict(attributes) + + +class CasaTunesSystem(CasaTunesObject): + """Read-only accessors for System data.""" + + @property + def MACAddress(self): + return self.attributes.get("MACAddress", "") + + @property + def AppName(self): + return self.attributes.get("AppName", "") + + @property + def CasaTunesVersion(self): + return self.attributes.get("CasaTunesVersion", "") + + @property + def RESTServicesVersion(self): + return self.attributes.get("RESTServicesVersion", "") + + +class CasaTunesZone(CasaTunesObject): + """Read-only accessors for Zone data.""" + + @property + def ZoneID(self): + return self.attributes.get("ZoneID", None) + + @property + def Name(self): + return self.attributes.get("Name", "") + + @property + def Power(self): + return self.attributes.get("Power", None) + + @property + def SourceID(self): + return self.attributes.get("SourceID", None) + + @property + def Volume(self): + return self.attributes.get("Volume", None) + + @property + def Mute(self): + return self.attributes.get("Mute", False) + + @property + def Hidden(self): + return self.attributes.get("Hidden", False) + + @property + def EnabledSources(self): + return self.attributes.get("EnabledSources", None) + + @property + def FixedVolumeEnabled(self): + return self.attributes.get("FixedVolumeEnabled", False) + + @property + def VolumeControlType(self): + return self.attributes.get("VolumeControlType", 1) + + @property + def HidePowerControl(self): + return self.attributes.get("HidePowerControl", False) + + @property + def HideSourceControl(self): + return self.attributes.get("HideSourceControl", False) + + @property + def SharedRoomID(self): + return self.attributes.get("SharedRoomID", 0) + + @property + def MasterMode(self): + return self.attributes.get("MasterMode", False) + + @property + def SleepEnabled(self): + return self.attributes.get("SleepEnabled", False) + + @property + def DND(self): + return self.attributes.get("DND", False) + + +class CasaTunesSource(CasaTunesObject): + """Read-only accessors for Source data.""" + + @property + def SourceID(self): + return self.attributes.get("SourceID", None) + + @property + def Name(self): + return self.attributes.get("Name", "") + + @property + def Hidden(self): + return self.attributes.get("Hidden", False) + + @property + def MediaTypesSupported(self): + return self.attributes.get("MediaTypesSupported", 0) + + @property + def Type(self): + return self.attributes.get("Type", None) + + @property + def SourceType(self): + return self.attributes.get("SourceType") + + +class CasaTunesNowPlaying(CasaTunesObject): + """Read-only accessors for NowPlaying data.""" + + @property + def SourceID(self): + return self.attributes.get("SourceID", None) + + @property + def Status(self): + return self.attributes.get("Status", 0) + + @property + def ShuffleMode(self): + return self.attributes.get("ShuffleMode", False) + + @property + def RepeatMode(self): + return self.attributes.get("RepeatMode", 0) + + @property + def CurrProgress(self): + return self.attributes.get("CurrProgress", None) + + @property + def CurrSong(self): + return CasaTunesSong(None, self.attributes.get("CurrSong") or {}) + + +class CasaTunesSong(CasaTunesObject): + """Read-only accessors for Song data.""" + + @property + def Title(self): + return self.attributes.get("Title", "") + + @property + def Artists(self): + return self.attributes.get("Artists", "") + + @property + def Album(self): + return self.attributes.get("Album", "") + + @property + def Duration(self): + return self.attributes.get("Duration", None) + + @property + def ArtworkURI(self): + return self.attributes.get("ArtworkURI", "") diff --git a/custom_components/casatunes/services.yaml b/custom_components/casatunes/services.yaml index 8ca4a52..569f741 100644 --- a/custom_components/casatunes/services.yaml +++ b/custom_components/casatunes/services.yaml @@ -89,7 +89,6 @@ tts: pre_wait: name: Pre-wait description: Specifies time to wait in seconds after powering before playing. - example: selector: number: min: 0 @@ -97,7 +96,6 @@ tts: post_wait: name: Post-wait description: Specifies time to wait in seconds before powering off (to avoid clipping). - example: selector: number: min: 0 @@ -105,7 +103,6 @@ tts: volume: name: Volume description: Specifies the volume to use for the room or room group. - example: selector: number: min: 0 @@ -130,7 +127,6 @@ doorbell: pre_wait: name: Pre-wait description: Specifies time to wait in seconds after powering on the zones and before playing the chime. - example: selector: number: min: 0 @@ -138,7 +134,6 @@ doorbell: post_wait: name: Post-wait description: Specifies time to wait in seconds before powering off the zones and after chime completed (to avoid clipping). - example: selector: number: min: 0 @@ -146,7 +141,6 @@ doorbell: volume: name: Volume description: Specifies the volume to use for the room or room group. - example: selector: number: min: 0 diff --git a/custom_components/casatunes/strings.json b/custom_components/casatunes/strings.json index cbc8936..fbeb57f 100644 --- a/custom_components/casatunes/strings.json +++ b/custom_components/casatunes/strings.json @@ -3,25 +3,38 @@ "config": { "step": { "user": { - "title": "[%key:common::config_flow::title:via_hassio_addon%]", + "title": "Connect to CasaTunes", "data": { - "host": "[%key:common::config_flow::data::host%]" + "host": "Host" } }, "discovery_confirm": { "title": "CasaTunes", - "description": "[%key:common::description::confirm_setup%]" + "description": "Set up {name}?" }, - "reauth_confirm": { - "title": "[%key:common::config_flow::title::reauth%]", + "reconfigure": { + "title": "Change CasaTunes address", "data": { - "host": "[%key:common::config_flow::data::host%]" + "host": "Host" } } }, + "error": { + "cannot_connect": "Unable to connect to CasaTunes.", + "unknown": "Unexpected connection error." + }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_configured": "This CasaTunes server is already configured.", + "already_in_progress": "Setup is already in progress.", + "cannot_connect": "Unable to connect to CasaTunes.", + "unknown": "Unexpected connection error.", + "unique_id_mismatch": "This address belongs to a different CasaTunes server.", + "reconfigure_successful": "CasaTunes address updated." + } + }, + "exceptions": { + "command_failed": { + "message": "CasaTunes could not complete the command. Check that the server is reachable and the selected source supports this action." } } } diff --git a/custom_components/casatunes/translations/en.json b/custom_components/casatunes/translations/en.json index 9a72a81..fbeb57f 100644 --- a/custom_components/casatunes/translations/en.json +++ b/custom_components/casatunes/translations/en.json @@ -1,26 +1,40 @@ { - "title": "CasaTunes", - "config": { - "step": { - "user": { - "title": "Fill in your CasaTunes IP Address", - "data": { - "host": "CasaTunes IP Address" - } - }, - "discovery_confirm": { - "description": "Do you want to start set up?" - }, - "reauth_confirm": { - "title": "Fill in your CasaTunes IP Address", - "data": { - "host": "CasaTunes IP Address" - } - } - }, - "abort": { - "already_configured": "Already configured. Only a single configuration possible.", - "already_in_progress": "Already in progress." + "title": "CasaTunes", + "config": { + "step": { + "user": { + "title": "Connect to CasaTunes", + "data": { + "host": "Host" } + }, + "discovery_confirm": { + "title": "CasaTunes", + "description": "Set up {name}?" + }, + "reconfigure": { + "title": "Change CasaTunes address", + "data": { + "host": "Host" + } + } + }, + "error": { + "cannot_connect": "Unable to connect to CasaTunes.", + "unknown": "Unexpected connection error." + }, + "abort": { + "already_configured": "This CasaTunes server is already configured.", + "already_in_progress": "Setup is already in progress.", + "cannot_connect": "Unable to connect to CasaTunes.", + "unknown": "Unexpected connection error.", + "unique_id_mismatch": "This address belongs to a different CasaTunes server.", + "reconfigure_successful": "CasaTunes address updated." + } + }, + "exceptions": { + "command_failed": { + "message": "CasaTunes could not complete the command. Check that the server is reachable and the selected source supports this action." } + } } diff --git a/hacs.json b/hacs.json index 0524040..e0650b0 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,6 @@ { "name": "CasaTunes", + "homeassistant": "2026.9.0", "country": "US", - "domains": "media_player", "render_readme": true -} \ No newline at end of file +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..8f32afa --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +pythonpath = . diff --git a/requirements.txt b/requirements.txt index b9e4f89..fda7482 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1 @@ -colorlog==6.7.0 -homeassistant==2023.2.0 -pip>=21.0,<23.2 -ruff==0.0.267 \ No newline at end of file +-r requirements_test.txt diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..cd590c8 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,2 @@ +pytest-homeassistant-custom-component==0.13.364 +ruff==0.16.6 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..51d0447 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,164 @@ +"""Transport contract tests; runnable without Home Assistant.""" + +import importlib.util +import sys +import types +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from aiohttp import ClientError + +package = types.ModuleType("casatunes_transport") +package.__path__ = [str(Path(__file__).parents[1] / "custom_components/casatunes")] +sys.modules[package.__name__] = package +spec = importlib.util.spec_from_file_location( + "casatunes_transport.api", Path(package.__path__[0]) / "api.py" +) +api = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = api +spec.loader.exec_module(api) +CasaException = api.CasaException + + +class Response: + def __init__(self, payload=None, error=None): + self.payload = payload + self.error = error + self.exited = False + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + self.exited = True + + def raise_for_status(self): + if self.error: + raise self.error + + async def json(self): + return self.payload + + +class Session: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def get(self, url, **kwargs): + self.calls.append((url, kwargs)) + return self.responses.pop(0) + + +@pytest.mark.parametrize( + "method,args,suffix", + [ + ("turn_on", (5,), "/zones/5?Power=on"), + ("turn_off", (5,), "/zones/5?Power=off"), + ("mute_volume", (5, False), "/zones/5?Mute=false"), + ("set_volume_level", (5, 30), "/zones/5?Volume=30"), + ("change_source", (5, 0), "/zones/5?SourceID=0"), + ("player_action", (5, "shuffle", True), "/zones/5/player/shuffle/true"), + ("player_action", (5, "Position", 0), "/zones/5/player/position/0"), + ("player_action", (5, "pause"), "/zones/5/player/pause"), + ("zone_join", (5, 2), "/zones/5/group/2"), + ("zone_unjoin", (5, 2), "/zones/5/ungroup/2"), + ("zone_master", (5, False), "/zones/5?MasterMode=false"), + ("clear_zone_queue", (5,), "/zones/5/queue/delete"), + ("queue_media", (5, "a/b", "add"), "/media/zones/5/play/a%2Fb/addtoqueue/add"), + ], +) +async def test_command_routes(method, args, suffix): + response = Response({"Result": True}) + session = Session([response]) + await getattr(api.CasaTunesClient(session, "server"), method)(*args) + assert session.calls[0][0] == "http://server:8735/api/v1" + suffix + assert session.calls[0][1]["timeout"].total == 10 + assert response.exited + + +@pytest.mark.parametrize( + "payload", + [{"Result": False}, {"Error": {"Message": "bad"}}, {"Error": {"HttpStatus": 500}}], +) +async def test_application_errors(payload): + with pytest.raises(CasaException): + await api.CasaTunesClient(Session([Response(payload)]), "server").turn_on(5) + + +@pytest.mark.parametrize("error", [TimeoutError(), ClientError(), ValueError()]) +async def test_transport_errors(error): + response = Response(error=error) + with pytest.raises(CasaException): + await api.CasaTunesClient(Session([response]), "server").turn_on(5) + assert response.exited + + +async def test_encoding_and_zero_parameters(): + session = Session([Response({})]) + await api.CasaTunesClient(session, "server").tts( + 5, {"input": "hello / & ?", "ssml": False, "volume": 0} + ) + assert "hello%20%2F%20%26%20%3F" in session.calls[0][0] + assert "ssml=false" in session.calls[0][0] + assert "volume=0" in session.calls[0][0] + + +def test_collection_shapes_and_ids(): + result = api.CasaTunesClient._normalize_collection( + {"zones": {"5": {"Power": True}}}, "zones", "ZoneID" + ) + assert result == [{"ZoneID": 5, "Power": True}] + + +@pytest.mark.parametrize( + "payload", + [ + [{"ZoneID": 5}], + [{"ZoneID": None, "Power": True}], + [{"ZoneID": 1, "Power": True}, {"ZoneID": "1", "Power": True}], + [None], + "bad", + ], +) +def test_invalid_zones(payload): + with pytest.raises(CasaException): + api.CasaTunesClient._normalize_collection(payload, "zones", "ZoneID") + + +async def test_atomic_fetch_and_static_cache(): + client = api.CasaTunesClient(None, "server") + client._get_json = AsyncMock( + side_effect=[ + [{"ZoneID": 5, "Power": True}], + [], + {"MACAddress": "aa:bb"}, + [], + [{"ZoneID": 5, "Power": False}], + CasaException("offline"), + ] + ) + before = await client.fetch() + with pytest.raises(CasaException): + await client.fetch() + assert client.data is before + assert client.data.zones_dict[5].Power + assert client._get_json.call_count == 6 + + +async def test_static_cache_reduces_poll_requests(): + client = api.CasaTunesClient(None, "server") + client._get_json = AsyncMock( + side_effect=[ + [], + [], + {"MACAddress": "aa:bb"}, + [], + [], + [], + ] + ) + await client.fetch() + await client.fetch() + assert client._get_json.call_count == 6 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..778f481 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,336 @@ +"""Integration tests against real Home Assistant classes and lifecycle.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +pytest.importorskip("homeassistant") + +from homeassistant.components.media_player import MediaPlayerEntityFeature as F +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.util.dt import utcnow +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.casatunes.api import CasaTunesClient +from custom_components.casatunes.coordinator import CasaTunesDataUpdateCoordinator +from custom_components.casatunes.media_player import CasaTunesMediaPlayer +from custom_components.casatunes.models import CasaException + + +async def snapshot(power=True, status=0, controls=511, source_type=1): + client = CasaTunesClient(None, "server") + client._get_json = AsyncMock( + side_effect=[ + [ + { + "ZoneID": 5, + "Name": "Study", + "Power": power, + "SourceID": 3, + "EnabledSources": 8, + } + ], + [ + { + "SourceID": 3, + "Status": status, + "Controls": controls, + "CurrProgress": 10, + "CurrSong": {"Duration": 200}, + } + ], + {"MACAddress": "aa:bb:cc:dd:ee:ff", "AppName": "CasaTunes"}, + [ + { + "SourceID": 3, + "Name": "PCs", + "Type": source_type, + "SourceType": 6 if source_type == 1 else 9, + "MediaTypesSupported": 33024 if source_type == 1 else 0, + }, + {"SourceID": 0, "Name": "Other", "MediaTypesSupported": 1}, + ], + ] + ) + return await client.fetch() + + +async def player(hass, **kwargs): + entry = MockConfigEntry(domain="casatunes", data={"host": "server"}) + coordinator = CasaTunesDataUpdateCoordinator(hass, entry, AsyncMock()) + coordinator.async_set_updated_data(await snapshot(**kwargs)) + coordinator.updated_at = utcnow() + ent = CasaTunesMediaPlayer( + coordinator, coordinator.data.zones[0], "aa:bb:cc:dd:ee:ff" + ) + ent.entity_id = "media_player.study" + ent.hass = hass + coordinator.entities = [ent] + coordinator.async_request_refresh = AsyncMock() + return ent + + +@pytest.mark.parametrize( + "status,expected", + [ + (0, "idle"), + (1, "paused"), + (2, "playing"), + (3, "buffering"), + (4, "buffering"), + (5, "buffering"), + (99, "on"), + ], +) +async def test_states(hass, status, expected): + ent = await player(hass, status=status) + assert ent.state == expected + + +async def test_power_precedes_playback(hass): + ent = await player(hass, power=False, status=2) + assert ent.state == "off" + + +async def test_position_has_no_read_side_effect(hass): + ent = await player(hass) + timestamp = ent.media_position_updated_at + assert ent.media_position == 10 + assert ent.media_position_updated_at == timestamp + assert ent.media_position == 10 + assert ent.media_position_updated_at == timestamp + + +async def test_source_and_feature_filtering(hass): + ent = await player(hass, source_type=4) + assert ent.source_list == ["PCs"] + assert not ent.supported_features & F.SEEK + assert ent.supported_features & F.TURN_ON + with pytest.raises(ServiceValidationError): + await ent.async_select_source("Other") + ent.coordinator.client.change_source.assert_not_called() + + +async def test_missing_zone_unavailable(hass): + ent = await player(hass) + ent.coordinator.data.zones_dict.clear() + assert not ent.available + assert ent.state == "idle" + + +async def test_cross_server_group_rejected_before_commands(hass): + ent = await player(hass) + with pytest.raises(ServiceValidationError): + await ent.async_join_players(["media_player.other_server"]) + ent.coordinator.client.zone_master.assert_not_called() + + +async def test_shuffle_and_power(hass): + ent = await player(hass) + assert ent.supported_features & F.SHUFFLE_SET + assert ent.supported_features & F.BROWSE_MEDIA + await ent.async_set_shuffle(True) + ent.coordinator.client.player_action.assert_awaited_once_with(5, "shuffle", True) + await ent.async_turn_on() + ent.coordinator.client.turn_on.assert_awaited_once_with(5) + + +async def test_volume_rounding(hass): + ent = await player(hass) + await ent.async_set_volume_level(0.29) + ent.coordinator.client.set_volume_level.assert_awaited_once_with(5, 29) + + +async def test_action_errors(hass): + ent = await player(hass) + ent.coordinator.client.turn_on.side_effect = CasaException("failed") + with pytest.raises(HomeAssistantError): + await ent.async_turn_on() + + +async def test_setup_services_and_unload(hass, enable_custom_integrations): + entry = MockConfigEntry( + domain="casatunes", data={"host": "server"}, unique_id="aa:bb:cc:dd:ee:ff" + ) + entry.add_to_hass(hass) + with patch( + "custom_components.casatunes.api.CasaTunesClient.fetch", + return_value=await snapshot(), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.services.has_service("casatunes", "search") + assert hass.services.has_service("casatunes", "tts") + assert hass.services.has_service("casatunes", "doorbell") + assert entry.runtime_data.entities + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_ssdp_flow(hass, enable_custom_integrations): + with patch( + "custom_components.casatunes.api.CasaTunesClient.fetch", + return_value=await snapshot(), + ): + result = await hass.config_entries.flow.async_init( + "casatunes", + context={"source": "ssdp"}, + data=SsdpServiceInfo( + ssdp_usn="uuid:test", + ssdp_st="test", + ssdp_location="http://server/desc.xml", + upnp={"friendlyName": "CasaTunes"}, + ), + ) + assert result["type"] == "form" + assert result["step_id"] == "discovery_confirm" + + +async def test_user_flow_unreachable(hass, enable_custom_integrations): + with patch( + "custom_components.casatunes.api.CasaTunesClient.fetch", + side_effect=CasaException("offline"), + ): + result = await hass.config_entries.flow.async_init( + "casatunes", context={"source": "user"}, data={"host": "server"} + ) + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_reconfigure_wrong_server(hass, enable_custom_integrations): + entry = MockConfigEntry( + domain="casatunes", data={"host": "old"}, unique_id="different" + ) + entry.add_to_hass(hass) + with patch( + "custom_components.casatunes.api.CasaTunesClient.fetch", + return_value=await snapshot(), + ): + result = await hass.config_entries.flow.async_init( + "casatunes", + context={"source": "reconfigure", "entry_id": entry.entry_id}, + data={"host": "new"}, + ) + assert result["type"] == "abort" + assert result["reason"] == "unique_id_mismatch" + assert entry.data["host"] == "old" + + +async def test_duplicate_user_flow(hass, enable_custom_integrations): + entry = MockConfigEntry( + domain="casatunes", data={"host": "server"}, unique_id="aa:bb:cc:dd:ee:ff" + ) + entry.add_to_hass(hass) + with patch( + "custom_components.casatunes.api.CasaTunesClient.fetch", + return_value=await snapshot(), + ): + result = await hass.config_entries.flow.async_init( + "casatunes", context={"source": "user"}, data={"host": "server"} + ) + assert result["reason"] == "already_configured" + + +async def test_setup_retry(hass, enable_custom_integrations): + entry = MockConfigEntry(domain="casatunes", data={"host": "server"}) + entry.add_to_hass(hass) + with patch( + "custom_components.casatunes.api.CasaTunesClient.fetch", + side_effect=CasaException("offline"), + ): + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state.value == "setup_retry" + assert hass.services.has_service("casatunes", "tts") + + +async def test_relative_volume_and_fixed_output(hass): + ent = await player(hass) + ent.zone.attributes["VolumeControlType"] = 2 + assert ent.supported_features & F.VOLUME_STEP + assert not ent.supported_features & F.VOLUME_SET + await ent.async_volume_up() + ent.coordinator.client.set_zone.assert_awaited_once_with(5, AdjustVolume=1) + ent.zone.attributes["FixedVolumeEnabled"] = True + assert not ent.supported_features & F.VOLUME_STEP + + +@pytest.mark.parametrize( + "enqueue,expected", [("replace", "playNow"), ("add", "add"), ("play", "addplay")] +) +async def test_queue_modes(hass, enqueue, expected): + ent = await player(hass) + await ent.async_play_media("track", "id", enqueue=enqueue) + ent.coordinator.client.play_media.assert_awaited_once_with( + 5, "id", add_to_queue=expected + ) + + +async def test_unsupported_queue_mode_no_command(hass): + ent = await player(hass) + with pytest.raises(ServiceValidationError): + await ent.async_play_media("track", "id", enqueue="next") + ent.coordinator.client.play_media.assert_not_called() + + +async def test_browse_and_api_error(hass): + from homeassistant.components.media_player.errors import BrowseError + + from custom_components.casatunes.browse_media import build_item_response + + ent = await player(hass) + ent.coordinator.client.get_media.return_value = { + "MediaItems": [ + {"ID": "album", "Title": "Album", "Flags": 8200}, + {"ID": "song", "Title": "Song", "Flags": 8193}, + ] + } + result = await build_item_response(5, ent.coordinator) + assert len(result.children) == 2 + assert result.children[0].can_play and result.children[0].can_expand + ent.coordinator.client.get_media.side_effect = CasaException("offline") + with pytest.raises(BrowseError): + await build_item_response(5, ent.coordinator) + + +async def test_diagnostics_redacts_identity(hass): + from custom_components.casatunes.diagnostics import ( + async_get_config_entry_diagnostics, + ) + + ent = await player(hass) + result = await async_get_config_entry_diagnostics( + hass, type("Entry", (), {"runtime_data": ent.coordinator})() + ) + assert "aa:bb" not in str(result) + assert "Study" not in str(result) + assert result["zones"][0]["power"] is True + + +async def test_group_members_and_last_client_cleanup(hass): + from custom_components.casatunes.models import CasaTunesZone + + master = await player(hass) + coordinator = master.coordinator + master.zone.attributes.update({"MasterMode": True, "SharedRoomID": 42}) + zone = CasaTunesZone(None, {"ZoneID": 2, "Power": True, "SharedRoomID": 42}) + coordinator.data.zones.append(zone) + coordinator.data.zones_dict[2] = zone + client = CasaTunesMediaPlayer(coordinator, zone, "aa:bb:cc:dd:ee:ff") + client.entity_id = "media_player.client" + client.hass = hass + coordinator.entities.append(client) + assert ( + master.group_members + == client.group_members + == ["media_player.study", "media_player.client"] + ) + + async def refresh(): + zone.attributes["SharedRoomID"] = 0 + + coordinator.async_refresh = AsyncMock(side_effect=refresh) + await client.async_unjoin_player() + coordinator.client.zone_unjoin.assert_awaited_once_with(5, 2) + coordinator.async_refresh.assert_awaited_once() + coordinator.client.zone_master.assert_awaited_once_with(5, False)