Compare commits

...

5 Commits

Author SHA1 Message Date
epenet 25fe3b08bd Adjust 2026-04-22 22:13:07 +00:00
epenet 29133e358c Move cast_browser, multizone_manager, added_cast_devices to runtime_data
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:09:18 +00:00
epenet fea0b75ab2 Use async_loaded_entries 2026-04-22 21:47:03 +00:00
epenet 6f8b6d41d5 Improve 2026-04-22 21:36:46 +00:00
epenet ff4816092f Migrate cast to use runtime_data
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:29:42 +00:00
8 changed files with 120 additions and 79 deletions
+37 -19
View File
@@ -1,11 +1,14 @@
"""Component to embed Google Cast."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from uuid import UUID
from pychromecast import Chromecast
from pychromecast.controllers.multizone import MultizoneManager
from pychromecast.discovery import CastBrowser
from homeassistant.components.media_player import BrowseMedia, MediaType
from homeassistant.config_entries import ConfigEntry
@@ -22,12 +25,41 @@ from .const import DOMAIN
PLATFORMS = [Platform.MEDIA_PLAYER]
type CastConfigEntry = ConfigEntry[CastRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@dataclass
class CastRuntimeData:
"""Runtime data for the Cast integration."""
cast_platform: dict[str, CastProtocol] = field(default_factory=dict)
unknown_models: dict[str | None, tuple[str | None, str | None]] = field(
default_factory=dict
)
added_cast_devices: set[UUID] = field(default_factory=set)
browser: CastBrowser | None = None
multizone_manager: MultizoneManager | None = None
async def async_setup_entry(hass: HomeAssistant, entry: CastConfigEntry) -> bool:
"""Set up Cast from a config entry."""
hass.data[DOMAIN] = {"cast_platform": {}, "unknown_models": {}}
entry.runtime_data = CastRuntimeData()
await home_assistant_cast.async_setup_ha_cast(hass, entry)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def _register_cast_platform(
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
) -> None:
"""Register a cast platform."""
if (
not hasattr(platform, "async_get_media_browser_root_object")
or not hasattr(platform, "async_browse_media")
or not hasattr(platform, "async_play_media")
):
raise HomeAssistantError(f"Invalid cast platform {platform}")
entry.runtime_data.cast_platform[integration_domain] = platform
await async_process_integration_platforms(hass, DOMAIN, _register_cast_platform)
return True
@@ -67,27 +99,13 @@ class CastProtocol(Protocol):
"""
@callback
def _register_cast_platform(
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
):
"""Register a cast platform."""
if (
not hasattr(platform, "async_get_media_browser_root_object")
or not hasattr(platform, "async_browse_media")
or not hasattr(platform, "async_play_media")
):
raise HomeAssistantError(f"Invalid cast platform {platform}")
hass.data[DOMAIN]["cast_platform"][integration_domain] = platform
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_remove_entry(hass: HomeAssistant, entry: CastConfigEntry) -> None:
"""Remove Home Assistant Cast user."""
await home_assistant_cast.async_remove_user(hass, entry)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant, config_entry: CastConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove cast config entry from a device.
+6 -8
View File
@@ -2,17 +2,12 @@
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components import onboarding
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_UUID
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
@@ -21,6 +16,9 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN
if TYPE_CHECKING:
from . import CastConfigEntry
IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
KNOWN_HOSTS_SCHEMA = vol.Schema(
{
@@ -42,7 +40,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
config_entry: CastConfigEntry,
) -> CastOptionsFlowHandler:
"""Get the options flow for this handler."""
return CastOptionsFlowHandler()
-7
View File
@@ -14,13 +14,6 @@ DOMAIN = "cast"
# Stores a threading.Lock that is held by the internal pychromecast discovery.
INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running"
# Stores UUIDs of cast devices that were added as entities. Doesn't store
# None UUIDs.
ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices"
# Stores an audio group manager.
CAST_MULTIZONE_MANAGER_KEY = "cast_multizone_manager"
# Store a CastBrowser
CAST_BROWSER_KEY = "cast_browser"
# Dispatcher signal fired with a ChromecastInfo every time we discover a new
# Chromecast or receive it through configuration
+15 -6
View File
@@ -1,18 +1,19 @@
"""Deal with Cast discovery."""
from __future__ import annotations
import logging
import threading
from typing import TYPE_CHECKING
import pychromecast.discovery
import pychromecast.models
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import (
CAST_BROWSER_KEY,
CONF_KNOWN_HOSTS,
INTERNAL_DISCOVERY_RUNNING_KEY,
SIGNAL_CAST_DISCOVERED,
@@ -20,6 +21,9 @@ from .const import (
)
from .helpers import ChromecastInfo, ChromeCastZeroconf
if TYPE_CHECKING:
from . import CastConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -49,7 +53,9 @@ def _remove_chromecast(hass: HomeAssistant, info: ChromecastInfo) -> None:
dispatcher_send(hass, SIGNAL_CAST_REMOVED, info)
def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
def setup_internal_discovery(
hass: HomeAssistant, config_entry: CastConfigEntry
) -> None:
"""Set up the pychromecast internal discovery."""
if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data:
hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock()
@@ -84,7 +90,7 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
ChromeCastZeroconf.get_zeroconf(),
config_entry.data.get(CONF_KNOWN_HOSTS),
)
hass.data[CAST_BROWSER_KEY] = browser
config_entry.runtime_data.browser = browser
browser.start_discovery()
def stop_discovery(event):
@@ -98,7 +104,10 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry.add_update_listener(config_entry_updated)
async def config_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
async def config_entry_updated(
hass: HomeAssistant, config_entry: CastConfigEntry
) -> None:
"""Handle config entry being updated."""
browser = hass.data[CAST_BROWSER_KEY]
browser = config_entry.runtime_data.browser
assert browser is not None
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))
+6 -3
View File
@@ -27,6 +27,8 @@ from .const import DOMAIN
if TYPE_CHECKING:
from homeassistant.components import zeroconf
from . import CastConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -65,9 +67,10 @@ class ChromecastInfo:
"""
cast_info = self.cast_info
if self.cast_info.cast_type is None or self.cast_info.manufacturer is None:
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
unknown_models = hass.data[DOMAIN]["unknown_models"]
entry: CastConfigEntry = next(
iter(hass.config_entries.async_loaded_entries(DOMAIN))
)
unknown_models = entry.runtime_data.unknown_models
if self.cast_info.model_name not in unknown_models:
# Manufacturer and cast type is not available in mDNS data,
# get it over HTTP
@@ -2,9 +2,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant import auth, config_entries, core
from homeassistant import auth, core
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, dispatcher, instance_id
@@ -13,6 +15,9 @@ from homeassistant.helpers.service import async_register_admin_service
from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW, HomeAssistantControllerData
if TYPE_CHECKING:
from . import CastConfigEntry
SERVICE_SHOW_VIEW = "show_lovelace_view"
ATTR_VIEW_PATH = "view_path"
ATTR_URL_PATH = "dashboard_path"
@@ -23,9 +28,7 @@ NO_URL_AVAILABLE_ERROR = (
)
async def async_setup_ha_cast(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
):
async def async_setup_ha_cast(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
"""Set up Home Assistant Cast."""
user_id: str | None = entry.data.get("user_id")
user: auth.models.User | None = None
@@ -89,9 +92,7 @@ async def async_setup_ha_cast(
)
async def async_remove_user(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
):
async def async_remove_user(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
"""Remove Home Assistant Cast user."""
user_id: str | None = entry.data.get("user_id")
+34 -24
View File
@@ -1,5 +1,4 @@
"""Provide functionality to interact with Cast devices on the network."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from __future__ import annotations
@@ -44,7 +43,6 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CAST_APP_ID_HOMEASSISTANT_LOVELACE,
CONF_UUID,
@@ -60,8 +58,6 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.logging import async_create_catching_coro
from .const import (
ADDED_CAST_DEVICES_KEY,
CAST_MULTIZONE_MANAGER_KEY,
CONF_IGNORE_CEC,
DOMAIN,
SIGNAL_CAST_DISCOVERED,
@@ -80,7 +76,7 @@ from .helpers import (
)
if TYPE_CHECKING:
from . import CastProtocol
from . import CastConfigEntry, CastProtocol
_LOGGER = logging.getLogger(__name__)
@@ -112,7 +108,9 @@ def api_error[_CastDeviceT: CastDevice, **_P, _R](
@callback
def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
def _async_create_cast_device(
hass: HomeAssistant, config_entry: CastConfigEntry, info: ChromecastInfo
):
"""Create a CastDevice entity or dynamic group from the chromecast object.
Returns None if the cast device has already been added.
@@ -123,7 +121,7 @@ def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
return None
# Found a cast with UUID
added_casts = hass.data[ADDED_CAST_DEVICES_KEY]
added_casts = config_entry.runtime_data.added_cast_devices
if info.uuid in added_casts:
# Already added this one, the entity will take care of moved hosts
# itself
@@ -133,21 +131,19 @@ def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
if info.is_dynamic_group:
# This is a dynamic group, do not add it but connect to the service.
group = DynamicCastGroup(hass, info)
group = DynamicCastGroup(hass, config_entry, info)
group.async_setup()
return None
return CastMediaPlayerEntity(hass, info)
return CastMediaPlayerEntity(hass, config_entry, info)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: CastConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cast from a config entry."""
hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
# Import CEC IGNORE attributes
pychromecast.IGNORE_CEC += config_entry.data.get(CONF_IGNORE_CEC) or []
@@ -162,7 +158,7 @@ async def async_setup_entry(
# UUID not matching, ignore.
return
cast_device = _async_create_cast_device(hass, discover)
cast_device = _async_create_cast_device(hass, config_entry, discover)
if cast_device is not None:
async_add_entities([cast_device])
@@ -181,13 +177,19 @@ class CastDevice:
_mz_only: bool
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
def __init__(
self,
hass: HomeAssistant,
config_entry: CastConfigEntry,
cast_info: ChromecastInfo,
) -> None:
"""Initialize the cast device."""
self.hass: HomeAssistant = hass
self._config_entry = config_entry
self._cast_info = cast_info
self._chromecast: pychromecast.Chromecast | None = None
self.mz_mgr = None
self.mz_mgr: MultizoneManager | None = None
self._status_listener: CastStatusListener | None = None
self._add_remove_handler: Callable[[], None] | None = None
self._del_remove_handler: Callable[[], None] | None = None
@@ -216,7 +218,9 @@ class CastDevice:
if self._cast_info.uuid is not None:
# Remove the entity from the added casts so that it can dynamically
# be re-added again.
self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid)
self._config_entry.runtime_data.added_cast_devices.remove(
self._cast_info.uuid
)
if self._add_remove_handler:
self._add_remove_handler()
self._add_remove_handler = None
@@ -239,10 +243,11 @@ class CastDevice:
)
self._chromecast = chromecast
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
runtime_data = self._config_entry.runtime_data
if runtime_data.multizone_manager is None:
runtime_data.multizone_manager = MultizoneManager()
self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
self.mz_mgr = runtime_data.multizone_manager
self._status_listener = CastStatusListener(
self, chromecast, self.mz_mgr, self._mz_only
@@ -302,10 +307,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
_attr_media_image_remotely_accessible = True
_mz_only = False
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
def __init__(
self,
hass: HomeAssistant,
config_entry: CastConfigEntry,
cast_info: ChromecastInfo,
) -> None:
"""Initialize the cast device."""
CastDevice.__init__(self, hass, cast_info)
CastDevice.__init__(self, hass, config_entry, cast_info)
self.cast_status = None
self.media_status = None
@@ -594,7 +604,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
"""Generate root node."""
children = []
# Add media browsers
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
for platform in self._config_entry.runtime_data.cast_platform.values():
children.extend(
await platform.async_get_media_browser_root_object(
self.hass, self._chromecast.cast_type
@@ -653,7 +663,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
platform: CastProtocol
assert media_content_type is not None
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
for platform in self._config_entry.runtime_data.cast_platform.values():
browse_media = await platform.async_browse_media(
self.hass,
media_content_type,
@@ -715,7 +725,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
return
# Try the cast platforms
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
for platform in self._config_entry.runtime_data.cast_platform.values():
result = await platform.async_play_media(
self.hass, self.entity_id, chromecast, media_type, media_id
)
+14 -5
View File
@@ -16,7 +16,10 @@ import pytest
import yarl
from homeassistant.components import media_player, tts
from homeassistant.components.cast import media_player as cast
from homeassistant.components.cast import (
CastRuntimeData,
media_player as cast_media_player,
)
from homeassistant.components.cast.const import (
DOMAIN,
SIGNAL_HASS_CAST_SHOW_VIEW,
@@ -486,22 +489,28 @@ async def test_stop_discovery_called_on_stop(
async def test_create_cast_device_without_uuid(hass: HomeAssistant) -> None:
"""Test create a cast device with no UUId does not create an entity."""
entry = MockConfigEntry(domain="cast")
entry.add_to_hass(hass)
entry.runtime_data = CastRuntimeData()
info = get_fake_chromecast_info(uuid=None)
cast_device = cast._async_create_cast_device(hass, info)
cast_device = cast_media_player._async_create_cast_device(hass, entry, info)
assert cast_device is None
async def test_create_cast_device_with_uuid(hass: HomeAssistant) -> None:
"""Test create cast devices with UUID creates entities."""
added_casts = hass.data[cast.ADDED_CAST_DEVICES_KEY] = set()
entry = MockConfigEntry(domain="cast")
entry.add_to_hass(hass)
entry.runtime_data = CastRuntimeData()
added_casts = entry.runtime_data.added_cast_devices
info = get_fake_chromecast_info()
cast_device = cast._async_create_cast_device(hass, info)
cast_device = cast_media_player._async_create_cast_device(hass, entry, info)
assert cast_device is not None
assert info.uuid in added_casts
# Sending second time should not create new entity
cast_device = cast._async_create_cast_device(hass, info)
cast_device = cast_media_player._async_create_cast_device(hass, entry, info)
assert cast_device is None