diff --git a/CODEOWNERS b/CODEOWNERS index a3853567fdc7ad..c287afc4c20228 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -734,6 +734,8 @@ CLAUDE.md @home-assistant/core /tests/components/homeassistant_sky_connect/ @home-assistant/core /homeassistant/components/homeassistant_yellow/ @home-assistant/core /tests/components/homeassistant_yellow/ @home-assistant/core +/homeassistant/components/homecast/ @parob +/tests/components/homecast/ @parob /homeassistant/components/homee/ @Taraman17 /tests/components/homee/ @Taraman17 /homeassistant/components/homekit/ @bdraco diff --git a/homeassistant/components/homecast/__init__.py b/homeassistant/components/homecast/__init__.py new file mode 100644 index 00000000000000..c4a7cd0d63b28c --- /dev/null +++ b/homeassistant/components/homecast/__init__.py @@ -0,0 +1,122 @@ +"""The Homecast integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from pyhomecast import HomecastClient, HomecastWebSocket + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .application_credentials import authorization_server_context +from .const import ( + API_BASE_URL, + CONF_API_URL, + CONF_MODE, + CONF_OAUTH_AUTHORIZE_URL, + CONF_OAUTH_TOKEN_URL, + DOMAIN as DOMAIN, + MODE_COMMUNITY, + OAUTH_AUTHORIZE_URL, + OAUTH_TOKEN_URL, +) +from .coordinator import HomecastCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [ + Platform.LIGHT, +] + + +@dataclass +class HomecastData: + """Runtime data for a Homecast config entry.""" + + coordinator: HomecastCoordinator + client: HomecastClient + + +type HomecastConfigEntry = ConfigEntry[HomecastData] + + +async def async_setup_entry(hass: HomeAssistant, entry: HomecastConfigEntry) -> bool: + """Set up Homecast from a config entry.""" + mode = entry.data.get(CONF_MODE) + api_url = entry.data.get(CONF_API_URL, API_BASE_URL) + + authorize_url = entry.data.get(CONF_OAUTH_AUTHORIZE_URL, OAUTH_AUTHORIZE_URL) + token_url = entry.data.get(CONF_OAUTH_TOKEN_URL, OAUTH_TOKEN_URL) + + with authorization_server_context( + AuthorizationServer(authorize_url=authorize_url, token_url=token_url) + ): + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + + try: + await session.async_ensure_token_valid() + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed from err + except OAuth2TokenRequestError as err: + raise ConfigEntryNotReady from err + + http_session = async_get_clientsession(hass) + client = HomecastClient(session=http_session, api_url=api_url) + client.authenticate(session.token[CONF_ACCESS_TOKEN]) + + device_id = f"ha_{entry.entry_id[:12]}" + ws = HomecastWebSocket( + session=http_session, + api_url=api_url, + device_id=device_id, + community=(mode == MODE_COMMUNITY), + ) + + async def _refresh_token() -> str: + """Refresh the OAuth token and return the new access token.""" + await session.async_ensure_token_valid() + token = session.token[CONF_ACCESS_TOKEN] + client.authenticate(token) + if ws: + ws.set_token(token) + return token + + coordinator = HomecastCoordinator( + hass, + entry, + client, + _refresh_token, + ws=ws, + initial_token=session.token[CONF_ACCESS_TOKEN], + ) + + await coordinator.async_config_entry_first_refresh() + + # Start WebSocket after initial state is available + await coordinator.async_setup_websocket() + + entry.runtime_data = HomecastData(coordinator=coordinator, client=client) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HomecastConfigEntry) -> bool: + """Unload a Homecast config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/homecast/application_credentials.py b/homeassistant/components/homecast/application_credentials.py new file mode 100644 index 00000000000000..d8aec69e4924bd --- /dev/null +++ b/homeassistant/components/homecast/application_credentials.py @@ -0,0 +1,76 @@ +"""OAuth application credentials for Homecast.""" + +from __future__ import annotations + +from contextlib import contextmanager +import contextvars + +from homeassistant.components.application_credentials import ( + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + LocalOAuth2ImplementationWithPkce, +) + +from .const import OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL, SCOPES + +# Context variable for dynamic OAuth server URLs (community mode). +# Set before calling into AbstractOAuth2FlowHandler so application_credentials +# resolves to the correct server (local community or cloud). +_server_context: contextvars.ContextVar[AuthorizationServer | None] = ( + contextvars.ContextVar("homecast_authorization_server", default=None) +) + + +@contextmanager +def authorization_server_context(server: AuthorizationServer): + """Temporarily override the authorization server URLs.""" + token = _server_context.set(server) + try: + yield + finally: + _server_context.reset(token) + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return the Homecast authorization server. + + Uses the context variable if set (community mode), otherwise cloud defaults. + """ + if (server := _server_context.get()) is not None: + return server + return AuthorizationServer( + authorize_url=OAUTH_AUTHORIZE_URL, + token_url=OAUTH_TOKEN_URL, + ) + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> HomecastOAuth2Implementation: + """Return a custom auth implementation with PKCE.""" + server = _server_context.get() + authorize_url = server.authorize_url if server else OAUTH_AUTHORIZE_URL + token_url = server.token_url if server else OAUTH_TOKEN_URL + + return HomecastOAuth2Implementation( + hass, + auth_domain, + credential.client_id, + authorize_url, + token_url, + credential.client_secret, + ) + + +class HomecastOAuth2Implementation(LocalOAuth2ImplementationWithPkce): + """Homecast OAuth2 implementation with PKCE (S256).""" + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return super().extra_authorize_data | { + "scope": SCOPES, + } diff --git a/homeassistant/components/homecast/config_flow.py b/homeassistant/components/homecast/config_flow.py new file mode 100644 index 00000000000000..043121b3bc51fd --- /dev/null +++ b/homeassistant/components/homecast/config_flow.py @@ -0,0 +1,224 @@ +"""Config flow for Homecast.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from pyhomecast import HomecastAuthError, HomecastClient, HomecastConnectionError +import voluptuous as vol + +from homeassistant.components.application_credentials import ( + AuthorizationServer, + ClientCredential, + async_import_client_credential, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler + +from .application_credentials import authorization_server_context +from .const import ( + API_BASE_URL, + CONF_API_URL, + CONF_MODE, + CONF_OAUTH_AUTHORIZE_URL, + CONF_OAUTH_TOKEN_URL, + DOMAIN, + MODE_CLOUD, + MODE_COMMUNITY, + SCOPES, +) + +_LOGGER = logging.getLogger(__name__) + +_REDIRECT_URI = "https://my.home-assistant.io/redirect/oauth" + + +class HomecastFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Handle a config flow for Homecast.""" + + DOMAIN = DOMAIN + + def __init__(self) -> None: + """Initialize the flow handler.""" + super().__init__() + self._community_data: dict[str, Any] | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return _LOGGER + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data to include in the authorize URL. + + Homecast OAuth requires explicit scope to grant device control access. + """ + return {"scope": SCOPES} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow start — choose cloud or community.""" + return self.async_show_menu( + step_id="user", + menu_options=["cloud", "community"], + ) + + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Cloud mode — dynamically register OAuth credentials.""" + client = HomecastClient( + session=async_get_clientsession(self.hass), api_url=API_BASE_URL + ) + try: + result = await client.register_client( + redirect_uri="https://my.home-assistant.io/redirect/oauth" + ) + except (HomecastConnectionError, HomecastAuthError) as err: + _LOGGER.error("Failed to register OAuth client: %s", err) + return self.async_abort(reason="cannot_connect") + + await async_import_client_credential( + self.hass, + DOMAIN, + ClientCredential( + result["client_id"], + result.get("client_secret", ""), + ), + ) + return await super().async_step_user(user_input) + + async def async_step_community( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Community mode — enter server URL, then OAuth.""" + errors: dict[str, str] = {} + + if user_input is not None: + api_url = user_input[CONF_API_URL].rstrip("/") + + # Dynamically register an OAuth client with the community server + client = HomecastClient( + session=async_get_clientsession(self.hass), api_url=api_url + ) + try: + result = await client.register_client(redirect_uri=_REDIRECT_URI) + except (HomecastConnectionError, HomecastAuthError) as err: + _LOGGER.error("Failed to register with community server: %s", err) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error registering with community server") + errors["base"] = "unknown" + else: + # Import the dynamically obtained credentials + await async_import_client_credential( + self.hass, + DOMAIN, + ClientCredential( + result["client_id"], + result.get("client_secret", ""), + ), + ) + + # Store community data for later steps + self._community_data = { + CONF_MODE: MODE_COMMUNITY, + CONF_API_URL: api_url, + CONF_OAUTH_AUTHORIZE_URL: f"{api_url}/oauth/authorize", + CONF_OAUTH_TOKEN_URL: f"{api_url}/oauth/token", + } + + # Kick off the OAuth flow against the community server + return await self.async_step_pick_implementation() + + return self.async_show_form( + step_id="community", + data_schema=vol.Schema( + { + vol.Required(CONF_API_URL, default="http://localhost:5656"): str, + } + ), + errors=errors, + ) + + async def async_step_pick_implementation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Override to set authorization server context for community mode.""" + if self._community_data: + with authorization_server_context( + AuthorizationServer( + authorize_url=self._community_data[CONF_OAUTH_AUTHORIZE_URL], + token_url=self._community_data[CONF_OAUTH_TOKEN_URL], + ) + ): + return await super().async_step_pick_implementation(user_input) + return await super().async_step_pick_implementation(user_input) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry after OAuth flow completes.""" + # Determine mode and API URL + if self._community_data: + api_url = self._community_data[CONF_API_URL] + mode = MODE_COMMUNITY + else: + api_url = API_BASE_URL + mode = MODE_CLOUD + + token = data[CONF_TOKEN][CONF_ACCESS_TOKEN] + client = HomecastClient( + session=async_get_clientsession(self.hass), api_url=api_url + ) + client.authenticate(token) + + try: + state = await client.get_state() + except HomecastAuthError: + return self.async_abort(reason="invalid_auth") + except HomecastConnectionError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected error during Homecast setup") + return self.async_abort(reason="unknown") + + _LOGGER.info("Homecast connected: found %d home(s)", len(state.homes)) + + # Use the first home's ID as a stable per-account unique identifier + home_ids = sorted(home.home_id or home.key for home in state.homes.values()) + unique_id = home_ids[0] if home_ids else api_url + await self.async_set_unique_id(unique_id) + + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=data + ) + + self._abort_if_unique_id_configured() + + # Merge community data (api_url, oauth URLs) into the entry + data[CONF_MODE] = mode + if self._community_data: + data.update(self._community_data) + + title = "Homecast Community" if mode == MODE_COMMUNITY else "Homecast" + return self.async_create_entry(title=title, data=data) diff --git a/homeassistant/components/homecast/const.py b/homeassistant/components/homecast/const.py new file mode 100644 index 00000000000000..e0646c5bef0a11 --- /dev/null +++ b/homeassistant/components/homecast/const.py @@ -0,0 +1,24 @@ +"""Constants for the Homecast integration.""" + +DOMAIN = "homecast" + +API_BASE_URL = "https://api.homecast.cloud" +OAUTH_AUTHORIZE_URL = f"{API_BASE_URL}/oauth/authorize" +OAUTH_TOKEN_URL = f"{API_BASE_URL}/oauth/token" + +# Pre-registered OAuth client for Home Assistant +OAUTH_CLIENT_ID = "6091cff0-a357-40f2-b9bc-babc60c338e6" +OAUTH_CLIENT_SECRET = "0c7Rw4h5q-rUTAuYMUuCzuE3qV1ubLGht8SKgA9OAu0" + +SCOPES = "read write" + +UPDATE_INTERVAL = 30 +UPDATE_INTERVAL_WS = 300 # Safety-net polling when WebSocket is connected + +# Community mode +CONF_MODE = "mode" +MODE_CLOUD = "cloud" +MODE_COMMUNITY = "community" +CONF_API_URL = "api_url" +CONF_OAUTH_AUTHORIZE_URL = "oauth_authorize_url" +CONF_OAUTH_TOKEN_URL = "oauth_token_url" diff --git a/homeassistant/components/homecast/coordinator.py b/homeassistant/components/homecast/coordinator.py new file mode 100644 index 00000000000000..ca9ad966027a15 --- /dev/null +++ b/homeassistant/components/homecast/coordinator.py @@ -0,0 +1,239 @@ +"""DataUpdateCoordinator for Homecast.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from datetime import timedelta +import logging +from typing import Any + +from pyhomecast import ( + HomecastAuthError, + HomecastClient, + HomecastConnectionError, + HomecastError, + HomecastState, + HomecastWebSocket, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL, UPDATE_INTERVAL_WS + +_LOGGER = logging.getLogger(__name__) + +# Map relay characteristic types to pyhomecast state keys. +# The relay sends friendly names (e.g. "brightness") which the server passes +# through in broadcasts. CHAR_TO_SIMPLE maps these to REST API state keys. +CHAR_TO_STATE_KEY: dict[str, str] = { + "on": "on", + "power_state": "on", + "active": "active", + "brightness": "brightness", + "hue": "hue", + "saturation": "saturation", + "color_temperature": "color_temp", + "current_temperature": "current_temp", + "heating_threshold": "heat_target", + "cooling_threshold": "cool_target", + "target_temperature": "target_temp", + "lock_current_state": "locked", + "lock_target_state": "lock_target", + "security_system_current_state": "alarm_state", + "security_system_target_state": "alarm_target", + "motion_detected": "motion", + "contact_state": "contact", + "battery_level": "battery", + "status_low_battery": "low_battery", + "volume": "volume", + "mute": "mute", +} + + +class HomecastCoordinator(DataUpdateCoordinator[HomecastState]): + """Coordinator that polls the Homecast REST API and receives WebSocket push updates.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + client: HomecastClient, + refresh_token: Callable[[], Coroutine[Any, Any, str]], + ws: HomecastWebSocket | None = None, + initial_token: str | None = None, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + self.client = client + self._refresh_token = refresh_token + self._ws = ws + self._current_token: str | None = initial_token + self._uuid_to_device: dict[str, str] = {} + + async def async_setup_websocket(self) -> None: + """Set up the WebSocket connection for push updates.""" + if not self._ws: + return + + self._ws.set_callback(self._on_ws_message) + + try: + if self._current_token: + await self._ws.connect(self._current_token) + except (HomecastAuthError, HomecastConnectionError) as err: + _LOGGER.warning("WebSocket connection failed, using polling: %s", err) + return + + # Subscribe to all homes + if self.data and self.data.homes: + await self._ws.subscribe(list(self.data.homes.keys())) + + # Build UUID-suffix to device key mapping + self._build_uuid_mapping() + + # Reduce polling frequency — WebSocket handles real-time updates + self.update_interval = timedelta(seconds=UPDATE_INTERVAL_WS) + _LOGGER.info("WebSocket connected, polling reduced to %ds", UPDATE_INTERVAL_WS) + + def _build_uuid_mapping(self) -> None: + """Build a mapping from (home_suffix, accessory_suffix) to device unique_id. + + The server broadcasts use HomeKit UUIDs (e.g. "3A14B2C1-...") while + pyhomecast uses slug keys ending with the last 4 chars of the UUID. + This mapping allows fast lookup from broadcast data. + """ + self._uuid_to_device.clear() + for unique_id, device in self.data.devices.items(): + # accessory_key is like "ceiling_light_c3d4" — last 4 chars are UUID suffix + acc_suffix = device.accessory_key[-4:] + home_suffix = device.home_key[-4:] + key = f"{home_suffix}:{acc_suffix}" + self._uuid_to_device[key] = unique_id + + def _resolve_device_key(self, home_id: str, accessory_id: str) -> str | None: + """Resolve a broadcast's homeId + accessoryId to a device unique_id.""" + key = f"{home_id[-4:].lower()}:{accessory_id[-4:].lower()}" + return self._uuid_to_device.get(key) + + def _on_ws_message(self, message: dict[str, Any]) -> None: + """Handle an incoming WebSocket broadcast message.""" + msg_type = message.get("type", "") + + if msg_type == "characteristic_update": + device_key = self._apply_state_update( + message.get("homeId"), + message.get("accessoryId"), + message.get("characteristicType", ""), + message.get("value"), + ) + # If this accessory is a member of a service group, propagate + # the state change to the group entity too + if device_key and self.data: + group_key = self.data.member_to_group.get(device_key) + if group_key: + group = self.data.devices.get(group_key) + if group: + char_type = message.get("characteristicType", "") + state_key = CHAR_TO_STATE_KEY.get(char_type) + if state_key: + group.state[state_key] = message.get("value") + self.async_set_updated_data(self.data) + elif msg_type == "service_group_update": + # Update the group entity itself + self._apply_state_update( + message.get("homeId"), + message.get("groupId"), + message.get("characteristicType", ""), + message.get("value"), + ) + # Group toggles also change all member accessories — full refresh + # to pick up their new states + self.hass.async_create_task(self.async_request_refresh()) + elif msg_type == "reachability_update": + self.hass.async_create_task(self.async_request_refresh()) + elif msg_type == "relay_status_update": + if not message.get("connected", True): + self.hass.async_create_task(self.async_request_refresh()) + + def _apply_state_update( + self, + home_id: str | None, + entity_id: str | None, + char_type: str, + value: Any, + ) -> str | None: + """Apply an incremental state update from a WS broadcast. + + Returns the device key if the update was applied, or None. + """ + if not self.data or not home_id or not entity_id: + return None + + device_key = self._resolve_device_key(home_id, entity_id) + if not device_key: + return None + + device = self.data.devices.get(device_key) + if not device: + return None + + state_key = CHAR_TO_STATE_KEY.get(char_type) + if not state_key: + return None + + device.state[state_key] = value + self.async_set_updated_data(self.data) + return device_key + + async def _async_update_data(self) -> HomecastState: + """Fetch state from the Homecast API.""" + try: + self._current_token = await self._refresh_token() + state = await self.client.get_state() + except HomecastAuthError as err: + raise ConfigEntryAuthFailed from err + except HomecastConnectionError as err: + raise UpdateFailed(f"Error communicating with Homecast: {err}") from err + + # Re-subscribe if new homes appeared + if self._ws and self._ws.connected and state.homes: + old_homes = set(self.data.homes.keys()) if self.data else set() + new_homes = set(state.homes.keys()) + if new_homes != old_homes: + await self._ws.subscribe(list(new_homes)) + + # Rebuild UUID mapping with fresh data + self.data = state + self._build_uuid_mapping() + + # Update WS token in case it was refreshed + if self._ws and self._current_token: + self._ws.set_token(self._current_token) + + return state + + async def async_set_state(self, updates: dict[str, Any]) -> None: + """Send a state update and request a refresh.""" + self._current_token = await self._refresh_token() + try: + await self.client.set_state(updates) + except HomecastAuthError as err: + raise ConfigEntryAuthFailed from err + except HomecastError as err: + _LOGGER.error("Failed to control device: %s", err) + await self.async_request_refresh() + + async def async_shutdown(self) -> None: + """Disconnect WebSocket on shutdown.""" + await super().async_shutdown() + if self._ws: + await self._ws.disconnect() diff --git a/homeassistant/components/homecast/entity.py b/homeassistant/components/homecast/entity.py new file mode 100644 index 00000000000000..5403a6ce743bbe --- /dev/null +++ b/homeassistant/components/homecast/entity.py @@ -0,0 +1,70 @@ +"""Base entity for Homecast.""" + +from __future__ import annotations + +from typing import Any + +from pyhomecast import HomecastDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HomecastCoordinator + + +class HomecastEntity(CoordinatorEntity[HomecastCoordinator]): + """Base class for Homecast entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HomecastCoordinator, + device: HomecastDevice, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._device_id = device.unique_id + self._attr_unique_id = device.unique_id + + # Prefix room name with home name when there are multiple homes + multiple_homes = len(coordinator.data.homes) > 1 + area = ( + f"{device.home_name} - {device.room_name}" + if multiple_homes + else device.room_name + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.unique_id)}, + name=device.name, + manufacturer="Homecast (HomeKit)", + model=device.device_type.replace("_", " ").title(), + suggested_area=area, + ) + + @property + def device(self) -> HomecastDevice | None: + """Return the current device data from the coordinator.""" + return self.coordinator.data.devices.get(self._device_id) + + @property + def available(self) -> bool: + """Return True if the device is available.""" + return super().available and self.device is not None + + async def _async_set_state(self, props: dict[str, Any]) -> None: + """Send a state update for this device.""" + device = self.device + if device is None: + return + await self.coordinator.async_set_state( + { + device.home_key: { + device.room_key: { + device.accessory_key: props, + }, + }, + } + ) diff --git a/homeassistant/components/homecast/light.py b/homeassistant/components/homecast/light.py new file mode 100644 index 00000000000000..4dc5d520a5e21d --- /dev/null +++ b/homeassistant/components/homecast/light.py @@ -0,0 +1,146 @@ +"""Light platform for Homecast.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomecastConfigEntry +from .entity import HomecastEntity + +# Mirek <-> Kelvin conversion +MIN_MIREK = 140 # ~7143K (cool white) +MAX_MIREK = 500 # 2000K (warm white) +MIN_KELVIN = round(1_000_000 / MAX_MIREK) # 2000 +MAX_KELVIN = round(1_000_000 / MIN_MIREK) # 7143 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomecastConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Homecast lights.""" + coordinator = entry.runtime_data.coordinator + + async_add_entities( + HomecastLight(coordinator, device) + for device in (coordinator.data.devices.values() if coordinator.data else []) + if device.device_type == "light" + ) + + +class HomecastLight(HomecastEntity, LightEntity): + """Represents a Homecast light.""" + + _attr_name = None + + @property + def color_mode(self) -> ColorMode: + """Return the active color mode.""" + device = self.device + if device is None: + return ColorMode.ONOFF + settable = device.settable + if "hue" in settable and "saturation" in settable: + return ColorMode.HS + if "color_temp" in settable: + return ColorMode.COLOR_TEMP + if "brightness" in settable: + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF + + @property + def supported_color_modes(self) -> set[ColorMode]: + """Return supported color modes.""" + modes: set[ColorMode] = set() + device = self.device + if device is None: + return {ColorMode.ONOFF} + settable = device.settable + if "hue" in settable and "saturation" in settable: + modes.add(ColorMode.HS) + if "color_temp" in settable: + modes.add(ColorMode.COLOR_TEMP) + if "brightness" in settable and not modes: + modes.add(ColorMode.BRIGHTNESS) + if not modes: + modes.add(ColorMode.ONOFF) + return modes + + @property + def is_on(self) -> bool | None: + """Return true if the light is on.""" + device = self.device + if device is None: + return None + return device.state.get("on") + + @property + def brightness(self) -> int | None: + """Return brightness (HA uses 0-255).""" + device = self.device + if device is None: + return None + val = device.state.get("brightness") + if val is not None: + return round(val * 255 / 100) + return None + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hue and saturation color value.""" + device = self.device + if device is None: + return None + hue = device.state.get("hue") + sat = device.state.get("saturation") + if hue is not None and sat is not None: + return (float(hue), float(sat)) + return None + + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature in Kelvin.""" + device = self.device + if device is None: + return None + mirek = device.state.get("color_temp") + if mirek is not None and mirek > 0: + return round(1_000_000 / mirek) + return None + + @property + def min_color_temp_kelvin(self) -> int: + """Return the minimum color temperature in Kelvin.""" + return MIN_KELVIN + + @property + def max_color_temp_kelvin(self) -> int: + """Return the maximum color temperature in Kelvin.""" + return MAX_KELVIN + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + payload: dict[str, Any] = {"on": True} + if ATTR_BRIGHTNESS in kwargs: + payload["brightness"] = round(kwargs[ATTR_BRIGHTNESS] / 255 * 100) + if ATTR_HS_COLOR in kwargs: + payload["hue"] = round(kwargs[ATTR_HS_COLOR][0]) + payload["saturation"] = round(kwargs[ATTR_HS_COLOR][1]) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + payload["color_temp"] = round(1_000_000 / kwargs[ATTR_COLOR_TEMP_KELVIN]) + await self._async_set_state(payload) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self._async_set_state({"on": False}) diff --git a/homeassistant/components/homecast/manifest.json b/homeassistant/components/homecast/manifest.json new file mode 100644 index 00000000000000..9de8133b41c820 --- /dev/null +++ b/homeassistant/components/homecast/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "homecast", + "name": "Homecast", + "codeowners": ["@parob"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/homecast", + "integration_type": "hub", + "iot_class": "cloud_push", + "loggers": ["pyhomecast"], + "quality_scale": "bronze", + "requirements": ["pyhomecast==0.3.0"] +} diff --git a/homeassistant/components/homecast/quality_scale.yaml b/homeassistant/components/homecast/quality_scale.yaml new file mode 100644 index 00000000000000..4109198026a98f --- /dev/null +++ b/homeassistant/components/homecast/quality_scale.yaml @@ -0,0 +1,26 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done diff --git a/homeassistant/components/homecast/strings.json b/homeassistant/components/homecast/strings.json new file mode 100644 index 00000000000000..6880235461b2e3 --- /dev/null +++ b/homeassistant/components/homecast/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "create_entry": { + "default": "Connected to Homecast. Your HomeKit devices will appear shortly." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "community": { + "data": { + "api_url": "Server URL" + }, + "data_description": { + "api_url": "The URL of your local Homecast server (e.g. http://192.168.1.100:5656)." + }, + "description": "Enter your Homecast server URL. You'll be asked to log in on your server.", + "title": "Connect to Homecast Community" + }, + "pick_implementation": { + "title": "Connect to Homecast" + }, + "reauth_confirm": { + "description": "Your Homecast session has expired. Please re-authenticate to continue controlling your devices.", + "title": "Re-authenticate with Homecast" + }, + "user": { + "menu_options": { + "cloud": "Homecast Cloud", + "community": "Homecast Community" + }, + "title": "Connect to Homecast" + } + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index a520338e91629c..d9a08f0645f9ad 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -20,6 +20,7 @@ "google_sheets", "google_tasks", "home_connect", + "homecast", "husqvarna_automower", "iotty", "lametric", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 13a421d03185d7..5cbc0194b94a67 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -300,6 +300,7 @@ "home_connect", "homeassistant_connect_zbt2", "homeassistant_sky_connect", + "homecast", "homee", "homekit", "homekit_controller", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e544f83988a0bb..89ab4be120f319 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2896,6 +2896,12 @@ "config_flow": false, "single_config_entry": true }, + "homecast": { + "name": "Homecast", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "homee": { "name": "Homee", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 068fbdc6eed81f..12e8a599c73a12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2160,6 +2160,9 @@ pyheos==1.0.6 # homeassistant.components.hive pyhive-integration==1.0.8 +# homeassistant.components.homecast +pyhomecast==0.3.0 + # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06602034de967c..48052a6fc9bf7c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1852,6 +1852,9 @@ pyheos==1.0.6 # homeassistant.components.hive pyhive-integration==1.0.8 +# homeassistant.components.homecast +pyhomecast==0.3.0 + # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/tests/components/homecast/__init__.py b/tests/components/homecast/__init__.py new file mode 100644 index 00000000000000..8349032e3cdec4 --- /dev/null +++ b/tests/components/homecast/__init__.py @@ -0,0 +1 @@ +"""Tests for the Homecast integration.""" diff --git a/tests/components/homecast/conftest.py b/tests/components/homecast/conftest.py new file mode 100644 index 00000000000000..a65f05bce583e5 --- /dev/null +++ b/tests/components/homecast/conftest.py @@ -0,0 +1,186 @@ +"""Test configuration and fixtures for the Homecast integration.""" + +from collections.abc import Generator +import copy +import time +from unittest.mock import AsyncMock, patch + +from pyhomecast import HomecastDevice, HomecastHome, HomecastState +import pytest + +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.homecast.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +MOCK_STATE = HomecastState( + devices={ + "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4": HomecastDevice( + unique_id="my_home_0bf8.living_room_a1b2.ceiling_light_c3d4", + name="Ceiling Light", + room_name="Living Room", + home_key="my_home_0bf8", + home_name="My Home", + room_key="living_room_a1b2", + accessory_key="ceiling_light_c3d4", + device_type="light", + state={"on": True, "brightness": 80, "hue": 45, "saturation": 100}, + settable=["on", "brightness", "hue", "saturation"], + ), + "my_home_0bf8.living_room_a1b2.smart_plug_e5f6": HomecastDevice( + unique_id="my_home_0bf8.living_room_a1b2.smart_plug_e5f6", + name="Smart Plug", + room_name="Living Room", + home_key="my_home_0bf8", + home_name="My Home", + room_key="living_room_a1b2", + accessory_key="smart_plug_e5f6", + device_type="switch", + state={"on": False}, + settable=["on"], + ), + "my_home_0bf8.bedroom_7890.thermostat_abcd": HomecastDevice( + unique_id="my_home_0bf8.bedroom_7890.thermostat_abcd", + name="Thermostat", + room_name="Bedroom", + home_key="my_home_0bf8", + home_name="My Home", + room_key="bedroom_7890", + accessory_key="thermostat_abcd", + device_type="climate", + state={ + "current_temp": 20.5, + "heat_target": 21.0, + "cool_target": 25.0, + "hvac_mode": "auto", + "hvac_state": "heating", + "active": True, + }, + settable=["heat_target", "cool_target", "hvac_mode", "active"], + ), + "my_home_0bf8.hallway_1234.front_door_5678": HomecastDevice( + unique_id="my_home_0bf8.hallway_1234.front_door_5678", + name="Front Door", + room_name="Hallway", + home_key="my_home_0bf8", + home_name="My Home", + room_key="hallway_1234", + accessory_key="front_door_5678", + device_type="lock", + state={"locked": True}, + settable=["lock_target"], + ), + "my_home_0bf8.hallway_1234.motion_sensor_9abc": HomecastDevice( + unique_id="my_home_0bf8.hallway_1234.motion_sensor_9abc", + name="Motion Sensor", + room_name="Hallway", + home_key="my_home_0bf8", + home_name="My Home", + room_key="hallway_1234", + accessory_key="motion_sensor_9abc", + device_type="motion", + state={"motion": False, "battery": 85, "low_battery": False}, + settable=[], + ), + }, + homes={ + "my_home_0bf8": HomecastHome( + key="my_home_0bf8", + name="My Home", + home_id="EEBCDDC0-F66D-5BD2-8D0E-C28CEC3FB454", + ), + }, +) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the OAuth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to set up application credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential("CLIENT_ID", "CLIENT_SECRET"), + DOMAIN, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.homecast.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_homecast() -> Generator[AsyncMock]: + """Mock HomecastClient and HomecastWebSocket.""" + with ( + patch( + "homeassistant.components.homecast.HomecastClient", + autospec=True, + ) as mock_client_class, + patch( + "homeassistant.components.homecast.config_flow.HomecastClient", + new=mock_client_class, + ), + patch( + "homeassistant.components.homecast.HomecastWebSocket", + autospec=True, + ) as mock_ws_class, + ): + client = mock_client_class.return_value + client.get_state = AsyncMock(side_effect=lambda **kw: copy.deepcopy(MOCK_STATE)) + client.set_state = AsyncMock(return_value={"ok": True}) + client.run_scene = AsyncMock(return_value={"ok": True}) + client.register_client = AsyncMock( + return_value={"client_id": "test-id", "client_secret": "test-secret"} + ) + client.authenticate = lambda token: None # sync method, not async + client._token = "mock-access-token" + + ws = mock_ws_class.return_value + ws.connect = AsyncMock() + ws.disconnect = AsyncMock() + ws.subscribe = AsyncMock() + ws.set_callback = lambda cb: None + ws.set_token = lambda token: None + ws.connected = True + + yield client + + +@pytest.fixture +def mock_config_entry(expires_at: int) -> MockConfigEntry: + """Create a mock Homecast config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Homecast", + unique_id="EEBCDDC0-F66D-5BD2-8D0E-C28CEC3FB454", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": expires_at, + "scope": "mcp:read mcp:write", + }, + }, + ) diff --git a/tests/components/homecast/test_config_flow.py b/tests/components/homecast/test_config_flow.py new file mode 100644 index 00000000000000..b2bb3c9600433e --- /dev/null +++ b/tests/components/homecast/test_config_flow.py @@ -0,0 +1,406 @@ +"""Tests for the Homecast config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock + +from pyhomecast import HomecastAuthError, HomecastConnectionError +import pytest + +from homeassistant.components.homecast.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def _start_cloud_flow( + hass: HomeAssistant, +) -> dict: + """Init the config flow and select the cloud option from the menu.""" + menu = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert menu["type"] is FlowResultType.MENU + + return await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "cloud"} + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_homecast: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the complete OAuth config flow via cloud.""" + result = await _start_cloud_flow(hass) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert "oauth/authorize" in result["url"] + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://api.homecast.cloud/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read write", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Homecast" + assert result["data"]["token"]["access_token"] == "mock-access-token" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await _start_cloud_flow(hass) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://api.homecast.cloud/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read write", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (HomecastConnectionError("timeout"), "cannot_connect"), + (HomecastAuthError("unauthorized"), "invalid_auth"), + (RuntimeError("unexpected"), "unknown"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_get_state_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_homecast: AsyncMock, + mock_setup_entry: AsyncMock, + side_effect: Exception, + reason: str, +) -> None: + """Test that we handle errors from get_state during OAuth completion.""" + mock_homecast.get_state.side_effect = side_effect + + result = await _start_cloud_flow(hass) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://api.homecast.cloud/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read write", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauth flow updates the existing entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Reauth confirm → goes to menu → select cloud + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "cloud"} + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://api.homecast.cloud/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read write", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_register_client_failure( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test that we abort if dynamic client registration fails.""" + mock_homecast.register_client.side_effect = HomecastConnectionError("timeout") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "cloud"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_menu_shows_cloud_and_community( + hass: HomeAssistant, + mock_homecast: AsyncMock, +) -> None: + """Test that the first step shows a menu with cloud and community options.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + assert "cloud" in result["menu_options"] + assert "community" in result["menu_options"] + + +async def _start_community_flow( + hass: HomeAssistant, + server_url: str = "http://localhost:5656", +) -> dict: + """Init the config flow, select community, and submit the server URL.""" + menu = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert menu["type"] is FlowResultType.MENU + + # Select "community" from the menu + form = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "community"} + ) + assert form["type"] is FlowResultType.FORM + assert form["step_id"] == "community" + + # Submit the server URL + return await hass.config_entries.flow.async_configure( + form["flow_id"], {"api_url": server_url} + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_community_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_homecast: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the complete OAuth config flow via community mode.""" + server_url = "http://my-server.local:5656" + + # Start community flow — submits server URL, registers client, kicks off OAuth + result = await _start_community_flow(hass, server_url) + + # Should be at the external OAuth step + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert f"{server_url}/oauth/authorize" in result["url"] + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + + aioclient_mock.clear_requests() + aioclient_mock.post( + f"{server_url}/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read write", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Homecast Community" + assert result["data"]["token"]["access_token"] == "mock-access-token" + assert result["data"]["mode"] == "community" + assert result["data"]["api_url"] == server_url + assert result["data"]["oauth_authorize_url"] == f"{server_url}/oauth/authorize" + assert result["data"]["oauth_token_url"] == f"{server_url}/oauth/token" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_community_flow_register_client_connection_error( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test community flow shows error when register_client fails with connection error.""" + mock_homecast.register_client.side_effect = HomecastConnectionError("timeout") + + menu = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + form = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "community"} + ) + assert form["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + form["flow_id"], {"api_url": "http://bad-server.local:5656"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_community_flow_register_client_unexpected_error( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test community flow shows error when register_client raises unexpected exception.""" + mock_homecast.register_client.side_effect = RuntimeError("unexpected") + + menu = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + form = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "community"} + ) + + result = await hass.config_entries.flow.async_configure( + form["flow_id"], {"api_url": "http://bad-server.local:5656"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "unknown" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_community_flow_shows_form_initially( + hass: HomeAssistant, + mock_homecast: AsyncMock, +) -> None: + """Test that selecting community from menu shows the server URL form.""" + menu = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "community"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "community" diff --git a/tests/components/homecast/test_coordinator.py b/tests/components/homecast/test_coordinator.py new file mode 100644 index 00000000000000..e958a71f2eb5dd --- /dev/null +++ b/tests/components/homecast/test_coordinator.py @@ -0,0 +1,680 @@ +"""Tests for the Homecast coordinator.""" + +import copy +from unittest.mock import AsyncMock, patch + +from pyhomecast import ( + HomecastAuthError, + HomecastConnectionError, + HomecastDevice, + HomecastError, + HomecastHome, + HomecastState, +) +import pytest + +from homeassistant.components.homecast.coordinator import HomecastCoordinator +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from tests.common import MockConfigEntry + + +async def _setup_and_get_coordinator( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> HomecastCoordinator: + """Set up the integration and return the coordinator.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + return mock_config_entry.runtime_data.coordinator + + +async def test_ws_characteristic_update( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test WebSocket characteristic_update applies state change.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + # Verify initial brightness + device_key = "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4" + assert coordinator.data.devices[device_key].state["brightness"] == 80 + + # Simulate a brightness update via WebSocket + coordinator._on_ws_message( + { + "type": "characteristic_update", + "homeId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "accessoryId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + "characteristicType": "brightness", + "value": 50, + } + ) + + assert coordinator.data.devices[device_key].state["brightness"] == 50 + + +async def test_ws_characteristic_update_triggers_refresh_for_power( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that power-related characteristic updates apply state change.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + device_key = "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4" + assert coordinator.data.devices[device_key].state["on"] is True + + coordinator._on_ws_message( + { + "type": "characteristic_update", + "homeId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "accessoryId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + "characteristicType": "on", + "value": False, + } + ) + + assert coordinator.data.devices[device_key].state["on"] is False + + +async def test_ws_service_group_update( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test WebSocket service_group_update triggers refresh.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + with patch.object(coordinator, "async_request_refresh") as mock_refresh: + coordinator._on_ws_message( + { + "type": "service_group_update", + "homeId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "groupId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + "characteristicType": "on", + "value": True, + } + ) + mock_refresh.assert_called_once() + + +async def test_ws_reachability_update( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test WebSocket reachability_update triggers refresh.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + with patch.object(coordinator, "async_request_refresh") as mock_refresh: + coordinator._on_ws_message({"type": "reachability_update"}) + mock_refresh.assert_called_once() + + +async def test_ws_relay_status_disconnected( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test WebSocket relay_status_update triggers refresh when disconnected.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + with patch.object(coordinator, "async_request_refresh") as mock_refresh: + coordinator._on_ws_message({"type": "relay_status_update", "connected": False}) + mock_refresh.assert_called_once() + + +async def test_ws_relay_status_connected_no_refresh( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test WebSocket relay_status_update does NOT refresh when connected.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + with patch.object(coordinator, "async_request_refresh") as mock_refresh: + coordinator._on_ws_message({"type": "relay_status_update", "connected": True}) + mock_refresh.assert_not_called() + + +async def test_ws_unknown_message_type( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test WebSocket ignores unknown message types.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + with patch.object(coordinator, "async_request_refresh") as mock_refresh: + coordinator._on_ws_message({"type": "unknown_type"}) + mock_refresh.assert_not_called() + + +async def test_apply_state_update_unknown_device( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test _apply_state_update ignores unknown accessory IDs.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + # Should not raise — unknown device is silently ignored + coordinator._on_ws_message( + { + "type": "characteristic_update", + "homeId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXFFFF", + "accessoryId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXFFFF", + "characteristicType": "on", + "value": True, + } + ) + + +async def test_apply_state_update_unknown_char_type( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test _apply_state_update ignores unknown characteristic types.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + device_key = "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4" + original_state = dict(coordinator.data.devices[device_key].state) + + coordinator._on_ws_message( + { + "type": "characteristic_update", + "homeId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "accessoryId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + "characteristicType": "unknown_characteristic", + "value": 99, + } + ) + + # State should be unchanged + assert coordinator.data.devices[device_key].state == original_state + + +async def test_apply_state_update_missing_ids( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test _apply_state_update handles missing homeId/accessoryId.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + # Missing homeId + coordinator._on_ws_message( + { + "type": "characteristic_update", + "accessoryId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + "characteristicType": "on", + "value": True, + } + ) + + # Missing accessoryId + coordinator._on_ws_message( + { + "type": "characteristic_update", + "homeId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "characteristicType": "on", + "value": True, + } + ) + + +async def test_ws_setup_failure_falls_back_to_polling( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that WebSocket connection failure falls back to polling.""" + # Make the WS connect fail — the conftest already mocks HomecastWebSocket, + # so we patch the coordinator's setup method to simulate WS failure + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homecast.coordinator.HomecastCoordinator.async_setup_websocket", + new_callable=AsyncMock, + ) as mock_ws_setup: + # Simulate WS setup that doesn't reduce the polling interval + mock_ws_setup.return_value = None + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + coordinator = mock_config_entry.runtime_data.coordinator + # Polling interval should remain at the default (WS setup was mocked to no-op) + assert coordinator.update_interval.total_seconds() == 30 + + +async def test_ws_setup_connects_and_subscribes( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_setup_websocket connects, subscribes, and reduces polling.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + # The conftest mock auto-connects — verify polling was reduced + assert coordinator.update_interval.total_seconds() == 300 + + +async def test_uuid_mapping_resolves_devices( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test _build_uuid_mapping and _resolve_device_key.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + # "ceiling_light_c3d4" in home "my_home_0bf8" + # UUID suffix matching: home[-4:]="0bf8", accessory[-4:]="c3d4" + result = coordinator._resolve_device_key( + "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + ) + assert result == "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4" + + +async def test_async_shutdown_disconnects_ws( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_shutdown disconnects the WebSocket.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + await coordinator.async_shutdown() + # The WS mock's disconnect should have been called + assert coordinator._ws.disconnect.called + + +async def test_apply_state_update_sets_value( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test _apply_state_update writes to device state and notifies.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + device_key = "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4" + assert coordinator.data.devices[device_key].state["on"] is True + + coordinator._apply_state_update( + "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + "on", + False, + ) + + assert coordinator.data.devices[device_key].state["on"] is False + + +async def test_apply_state_update_returns_none_when_no_data( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test _apply_state_update returns None when self.data is None.""" + mock_config_entry.add_to_hass(hass) + + # Build the coordinator manually so we can call _apply_state_update + # before data is loaded + coordinator = HomecastCoordinator( + hass, + mock_config_entry, + mock_homecast, + refresh_token=AsyncMock(return_value="token"), + ws=None, + initial_token=None, + ) + + # data is None before first refresh + assert coordinator.data is None + + result = coordinator._apply_state_update( + "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + "on", + True, + ) + assert result is None + + +async def test_ws_setup_no_token_skips_connect( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_setup_websocket skips connect when _current_token is None.""" + mock_config_entry.add_to_hass(hass) + + # Create a WS mock directly (don't patch — conftest already patches the class) + ws = AsyncMock() + ws.connect = AsyncMock() + ws.disconnect = AsyncMock() + ws.subscribe = AsyncMock() + ws.set_callback = lambda cb: None + ws.set_token = lambda token: None + ws.connected = True + + coordinator = HomecastCoordinator( + hass, + mock_config_entry, + mock_homecast, + refresh_token=AsyncMock(return_value="token"), + ws=ws, + initial_token=None, # No token + ) + + # Load data so subscribe + uuid mapping can work + coordinator.data = copy.deepcopy(await mock_homecast.get_state()) + + await coordinator.async_setup_websocket() + + # connect should NOT have been called (no token) + ws.connect.assert_not_called() + # But subscribe should still be called since data has homes + ws.subscribe.assert_called_once() + + +async def test_ws_setup_connect_failure_falls_back( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_setup_websocket falls back to polling on connection error.""" + mock_config_entry.add_to_hass(hass) + + ws = AsyncMock() + ws.connect = AsyncMock(side_effect=HomecastConnectionError("connection refused")) + ws.disconnect = AsyncMock() + ws.subscribe = AsyncMock() + ws.set_callback = lambda cb: None + ws.set_token = lambda token: None + ws.connected = False + + coordinator = HomecastCoordinator( + hass, + mock_config_entry, + mock_homecast, + refresh_token=AsyncMock(return_value="token"), + ws=ws, + initial_token="mock-token", + ) + + coordinator.data = copy.deepcopy(await mock_homecast.get_state()) + + await coordinator.async_setup_websocket() + + # connect was called but raised — should return early + ws.connect.assert_called_once_with("mock-token") + # subscribe should NOT be called because connect failed + ws.subscribe.assert_not_called() + # Polling interval should remain at default (not reduced) + assert coordinator.update_interval.total_seconds() == 30 + + +async def test_ws_group_propagation( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test characteristic_update propagates to group when member_to_group is set.""" + # Create a state with a group and a member that maps to it + group_state = HomecastState( + devices={ + "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4": HomecastDevice( + unique_id="my_home_0bf8.living_room_a1b2.ceiling_light_c3d4", + name="Ceiling Light", + room_name="Living Room", + home_key="my_home_0bf8", + home_name="My Home", + room_key="living_room_a1b2", + accessory_key="ceiling_light_c3d4", + device_type="light", + state={"on": True, "brightness": 80}, + settable=["on", "brightness"], + ), + "my_home_0bf8.living_room_a1b2.light_group_ef01": HomecastDevice( + unique_id="my_home_0bf8.living_room_a1b2.light_group_ef01", + name="Light Group", + room_name="Living Room", + home_key="my_home_0bf8", + home_name="My Home", + room_key="living_room_a1b2", + accessory_key="light_group_ef01", + device_type="light", + state={"on": True, "brightness": 80}, + settable=["on", "brightness"], + ), + }, + homes={ + "my_home_0bf8": HomecastHome( + key="my_home_0bf8", + name="My Home", + home_id="EEBCDDC0-F66D-5BD2-8D0E-C28CEC3FB454", + ), + }, + member_to_group={ + # ceiling_light_c3d4 is a member of light_group_ef01 + "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4": "my_home_0bf8.living_room_a1b2.light_group_ef01", + }, + ) + + mock_homecast.get_state = AsyncMock( + side_effect=lambda **kw: copy.deepcopy(group_state) + ) + + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + # Verify initial state + group_key = "my_home_0bf8.living_room_a1b2.light_group_ef01" + assert coordinator.data.devices[group_key].state["brightness"] == 80 + + # Send a characteristic_update for the member device + coordinator._on_ws_message( + { + "type": "characteristic_update", + "homeId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "accessoryId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + "characteristicType": "brightness", + "value": 50, + } + ) + + # The member's state should be updated + member_key = "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4" + assert coordinator.data.devices[member_key].state["brightness"] == 50 + + # The group's state should also be propagated + assert coordinator.data.devices[group_key].state["brightness"] == 50 + + +async def test_ws_group_propagation_unknown_char_type( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test group propagation is skipped for unknown characteristic types.""" + group_state = HomecastState( + devices={ + "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4": HomecastDevice( + unique_id="my_home_0bf8.living_room_a1b2.ceiling_light_c3d4", + name="Ceiling Light", + room_name="Living Room", + home_key="my_home_0bf8", + home_name="My Home", + room_key="living_room_a1b2", + accessory_key="ceiling_light_c3d4", + device_type="light", + state={"on": True, "brightness": 80}, + settable=["on", "brightness"], + ), + "my_home_0bf8.living_room_a1b2.light_group_ef01": HomecastDevice( + unique_id="my_home_0bf8.living_room_a1b2.light_group_ef01", + name="Light Group", + room_name="Living Room", + home_key="my_home_0bf8", + home_name="My Home", + room_key="living_room_a1b2", + accessory_key="light_group_ef01", + device_type="light", + state={"on": True, "brightness": 80}, + settable=["on", "brightness"], + ), + }, + homes={ + "my_home_0bf8": HomecastHome( + key="my_home_0bf8", + name="My Home", + home_id="EEBCDDC0-F66D-5BD2-8D0E-C28CEC3FB454", + ), + }, + member_to_group={ + "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4": "my_home_0bf8.living_room_a1b2.light_group_ef01", + }, + ) + + mock_homecast.get_state = AsyncMock( + side_effect=lambda **kw: copy.deepcopy(group_state) + ) + + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + group_key = "my_home_0bf8.living_room_a1b2.light_group_ef01" + original_state = dict(coordinator.data.devices[group_key].state) + + # Send an update with unknown characteristic type — member state won't update, + # so _apply_state_update returns None and group propagation doesn't happen + coordinator._on_ws_message( + { + "type": "characteristic_update", + "homeId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "accessoryId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + "characteristicType": "unknown_type", + "value": 99, + } + ) + + # Group state should remain unchanged + assert coordinator.data.devices[group_key].state == original_state + + +async def test_async_set_state_auth_error( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_set_state raises ConfigEntryAuthFailed on HomecastAuthError.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + mock_homecast.set_state.side_effect = HomecastAuthError("unauthorized") + + with pytest.raises(ConfigEntryAuthFailed): + await coordinator.async_set_state({"some": "update"}) + + +async def test_async_set_state_generic_error( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_set_state logs error on HomecastError but does not raise.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + mock_homecast.set_state.side_effect = HomecastError("device timeout") + + # Should NOT raise — the error is logged and refresh is still requested + await coordinator.async_set_state({"some": "update"}) + + +async def test_ws_setup_no_ws_returns_early( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_setup_websocket returns immediately when ws is None.""" + mock_config_entry.add_to_hass(hass) + + coordinator = HomecastCoordinator( + hass, + mock_config_entry, + mock_homecast, + refresh_token=AsyncMock(return_value="token"), + ws=None, + initial_token="token", + ) + + # Should not raise — just returns early + await coordinator.async_setup_websocket() + assert coordinator.update_interval.total_seconds() == 30 + + +async def test_apply_state_update_device_removed( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test _apply_state_update returns None when device was removed from data.""" + coordinator = await _setup_and_get_coordinator( + hass, mock_homecast, mock_config_entry + ) + + # Remove the device from data but keep the UUID mapping stale + device_key = "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4" + del coordinator.data.devices[device_key] + + result = coordinator._apply_state_update( + "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX0BF8", + "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXC3D4", + "on", + False, + ) + assert result is None diff --git a/tests/components/homecast/test_init.py b/tests/components/homecast/test_init.py new file mode 100644 index 00000000000000..25080625972857 --- /dev/null +++ b/tests/components/homecast/test_init.py @@ -0,0 +1,113 @@ +"""Tests for the Homecast integration init.""" + +from unittest.mock import AsyncMock, patch + +from pyhomecast import HomecastAuthError, HomecastConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful setup of a config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.runtime_data is not None + assert mock_config_entry.runtime_data.coordinator is not None + + +async def test_unload_entry( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading a config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_connection_error( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup retries on connection error.""" + mock_homecast.get_state.side_effect = HomecastConnectionError("timeout") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_auth_error( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup triggers reauth on auth error from coordinator.""" + mock_homecast.get_state.side_effect = HomecastAuthError("unauthorized") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_token_refresh_reauth( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup triggers reauth when OAuth token refresh fails.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_token_refresh_error( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup fails when OAuth token request fails.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/homecast/test_light.py b/tests/components/homecast/test_light.py new file mode 100644 index 00000000000000..78cefe6bd91246 --- /dev/null +++ b/tests/components/homecast/test_light.py @@ -0,0 +1,339 @@ +"""Tests for the Homecast light platform.""" + +from unittest.mock import AsyncMock + +from pyhomecast import HomecastDevice, HomecastHome, HomecastState + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + DOMAIN as LIGHT_DOMAIN, + ColorMode, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +COLOR_TEMP_STATE = HomecastState( + devices={ + "my_home_0bf8.living_room_a1b2.desk_lamp_aa11": HomecastDevice( + unique_id="my_home_0bf8.living_room_a1b2.desk_lamp_aa11", + name="Desk Lamp", + room_name="Living Room", + home_key="my_home_0bf8", + home_name="My Home", + room_key="living_room_a1b2", + accessory_key="desk_lamp_aa11", + device_type="light", + state={"on": True, "brightness": 60, "color_temp": 250}, + settable=["on", "brightness", "color_temp"], + ), + }, + homes={"my_home_0bf8": HomecastHome(key="my_home_0bf8", name="My Home")}, +) + +BRIGHTNESS_ONLY_STATE = HomecastState( + devices={ + "my_home_0bf8.living_room_a1b2.dimmer_bb22": HomecastDevice( + unique_id="my_home_0bf8.living_room_a1b2.dimmer_bb22", + name="Dimmer", + room_name="Living Room", + home_key="my_home_0bf8", + home_name="My Home", + room_key="living_room_a1b2", + accessory_key="dimmer_bb22", + device_type="light", + state={"on": False, "brightness": 0}, + settable=["on", "brightness"], + ), + }, + homes={"my_home_0bf8": HomecastHome(key="my_home_0bf8", name="My Home")}, +) + +ONOFF_ONLY_STATE = HomecastState( + devices={ + "my_home_0bf8.living_room_a1b2.bulb_cc33": HomecastDevice( + unique_id="my_home_0bf8.living_room_a1b2.bulb_cc33", + name="Bulb", + room_name="Living Room", + home_key="my_home_0bf8", + home_name="My Home", + room_key="living_room_a1b2", + accessory_key="bulb_cc33", + device_type="light", + state={"on": True}, + settable=["on"], + ), + }, + homes={"my_home_0bf8": HomecastHome(key="my_home_0bf8", name="My Home")}, +) + +MULTI_HOME_STATE = HomecastState( + devices={ + "my_home_0bf8.living_room_a1b2.ceiling_light_c3d4": HomecastDevice( + unique_id="my_home_0bf8.living_room_a1b2.ceiling_light_c3d4", + name="Ceiling Light", + room_name="Living Room", + home_key="my_home_0bf8", + home_name="My Home", + room_key="living_room_a1b2", + accessory_key="ceiling_light_c3d4", + device_type="light", + state={"on": True, "brightness": 80, "hue": 45, "saturation": 100}, + settable=["on", "brightness", "hue", "saturation"], + ), + "beach_house_1234.patio_5678.string_lights_abcd": HomecastDevice( + unique_id="beach_house_1234.patio_5678.string_lights_abcd", + name="String Lights", + room_name="Patio", + home_key="beach_house_1234", + home_name="Beach House", + room_key="patio_5678", + accessory_key="string_lights_abcd", + device_type="light", + state={"on": False}, + settable=["on"], + ), + }, + homes={ + "my_home_0bf8": HomecastHome(key="my_home_0bf8", name="My Home"), + "beach_house_1234": HomecastHome(key="beach_house_1234", name="Beach House"), + }, +) + + +async def test_light_setup( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that light entities are created for light devices.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("light.ceiling_light") + assert state is not None + assert state.state == "on" + + +async def test_light_brightness( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light brightness is converted from 0-100 to 0-255.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("light.ceiling_light") + assert state is not None + # 80% -> 204/255 + assert state.attributes.get(ATTR_BRIGHTNESS) == round(80 * 255 / 100) + + +async def test_light_hs_color( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light HS color.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("light.ceiling_light") + assert state is not None + assert state.attributes.get(ATTR_HS_COLOR) == (45.0, 100.0) + + +async def test_light_color_mode( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light reports HS color mode when hue+saturation are settable.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("light.ceiling_light") + assert state is not None + assert state.attributes.get("color_mode") == ColorMode.HS + + +async def test_light_turn_on( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning on a light.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.ceiling_light", ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + mock_homecast.set_state.assert_called_once() + call_args = mock_homecast.set_state.call_args[0][0] + props = call_args["my_home_0bf8"]["living_room_a1b2"]["ceiling_light_c3d4"] + assert props["on"] is True + assert props["brightness"] == round(128 / 255 * 100) + + +async def test_light_turn_on_with_hs_color( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning on a light with HS color.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.ceiling_light", ATTR_HS_COLOR: (120.0, 50.0)}, + blocking=True, + ) + + mock_homecast.set_state.assert_called_once() + call_args = mock_homecast.set_state.call_args[0][0] + props = call_args["my_home_0bf8"]["living_room_a1b2"]["ceiling_light_c3d4"] + assert props["on"] is True + assert props["hue"] == 120 + assert props["saturation"] == 50 + + +async def test_light_turn_off( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning off a light.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.ceiling_light"}, + blocking=True, + ) + + mock_homecast.set_state.assert_called_once() + call_args = mock_homecast.set_state.call_args[0][0] + props = call_args["my_home_0bf8"]["living_room_a1b2"]["ceiling_light_c3d4"] + assert props["on"] is False + + +async def test_light_color_temp_mode( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light with color_temp settable reports COLOR_TEMP mode.""" + mock_homecast.get_state = AsyncMock(return_value=COLOR_TEMP_STATE) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("light.desk_lamp") + assert state is not None + assert state.attributes.get("color_mode") == ColorMode.COLOR_TEMP + assert state.attributes.get("supported_color_modes") == [ColorMode.COLOR_TEMP] + # 250 mirek -> 4000K + assert state.attributes.get("color_temp_kelvin") == 4000 + assert state.attributes.get("min_color_temp_kelvin") == 2000 + assert state.attributes.get("max_color_temp_kelvin") == 7143 + + +async def test_light_turn_on_with_color_temp( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning on a light with color temperature.""" + mock_homecast.get_state = AsyncMock(return_value=COLOR_TEMP_STATE) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.desk_lamp", ATTR_COLOR_TEMP_KELVIN: 3000}, + blocking=True, + ) + + mock_homecast.set_state.assert_called_once() + call_args = mock_homecast.set_state.call_args[0][0] + props = call_args["my_home_0bf8"]["living_room_a1b2"]["desk_lamp_aa11"] + assert props["on"] is True + assert props["color_temp"] == round(1_000_000 / 3000) + + +async def test_light_brightness_only_mode( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light with only brightness settable reports BRIGHTNESS mode.""" + mock_homecast.get_state = AsyncMock(return_value=BRIGHTNESS_ONLY_STATE) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("light.dimmer") + assert state is not None + assert state.attributes.get("supported_color_modes") == [ColorMode.BRIGHTNESS] + + +async def test_light_onoff_only_mode( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light with only on/off settable reports ONOFF mode.""" + mock_homecast.get_state = AsyncMock(return_value=ONOFF_ONLY_STATE) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("light.bulb") + assert state is not None + assert state.attributes.get("color_mode") == ColorMode.ONOFF + assert state.attributes.get("supported_color_modes") == [ColorMode.ONOFF] + + +async def test_light_multi_home_area_naming( + hass: HomeAssistant, + mock_homecast: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that multi-home setups prefix room name with home name.""" + mock_homecast.get_state = AsyncMock(return_value=MULTI_HOME_STATE) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Both homes' lights should exist + state1 = hass.states.get("light.ceiling_light") + state2 = hass.states.get("light.string_lights") + assert state1 is not None + assert state2 is not None