diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cc55aa550b070c..6efa808ce19491 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -474,6 +474,32 @@ jobs: run: | uv run --no-project python -m script.gen_copilot_instructions validate + gen-recorder-db-versions: + name: Check recorder database versions + runs-on: ubuntu-24.04 + permissions: + contents: read + needs: + - info + # Only run on push to the dev branch; this job reaches out to endoflife.date, and + # we do not want a new MariaDB/MySQL release to fail CI on PR runs or the rc/master + # branches. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Python + id: python + uses: ./.github/actions/setup-uv-python + with: + uv-version: ${{ needs.info.outputs.uv_version }} + python-version: ${{ needs.info.outputs.default_python }} + - name: Check MariaDB and MySQL versions are up to date + run: | + uv run --no-project python -m script.gen_recorder_db_versions validate + dependency-review: name: Dependency review runs-on: ubuntu-24.04 diff --git a/homeassistant/components/actron_air/manifest.json b/homeassistant/components/actron_air/manifest.json index a21ba35947e862..314f0506c41549 100644 --- a/homeassistant/components/actron_air/manifest.json +++ b/homeassistant/components/actron_air/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["actron-neo-api==0.5.13"] + "requirements": ["actron-neo-api==0.5.14"] } diff --git a/homeassistant/components/ariston/__init__.py b/homeassistant/components/ariston/__init__.py new file mode 100644 index 00000000000000..8fc1cb48fc2764 --- /dev/null +++ b/homeassistant/components/ariston/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Ariston.""" diff --git a/homeassistant/components/ariston/manifest.json b/homeassistant/components/ariston/manifest.json new file mode 100644 index 00000000000000..83d61f9dcec09c --- /dev/null +++ b/homeassistant/components/ariston/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ariston", + "name": "Ariston", + "integration_type": "virtual", + "supported_by": "midea" +} diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index a962c1fb68534d..eea633b7b5aa23 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -79,6 +79,10 @@ HTTPStatus.BAD_GATEWAY, "Unable to reach the Home Assistant Cloud.", ), + auth.AuthTimeoutError: ( + HTTPStatus.GATEWAY_TIMEOUT, + "Authentication timed out.", + ), aiohttp.ClientError: ( HTTPStatus.INTERNAL_SERVER_ERROR, "Error making internal request", diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index bd5f79524ac469..24a6452041e21c 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==2.2.0", "openai==2.45.0"], + "requirements": ["hass-nabucasa==2.3.0", "openai==2.45.0"], "single_config_entry": true } diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 462448c5536a07..0a93b68cd532bf 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -180,7 +180,8 @@ def websocket_update_device( msg["labels"] = set(msg["labels"]) entry: dr.AnyDeviceEntry | None - if msg["device_id"] in registry.child_devices: + device = registry.async_get(msg["device_id"], include_composite_devices=False) + if isinstance(device, dr.ChildDeviceEntry): entry = registry.async_update_child_device(**msg) else: entry = registry.async_update_device(**msg) @@ -207,10 +208,16 @@ async def _async_remove_device( device_id = msg["device_id"] # A composite device id has no single underlying device to remove; reject it. - if registry.async_is_composite_device_id(device_id): + if ( + registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is not None + ): raise HomeAssistantError("Cannot remove a composite device") - - if (device_entry := registry.async_get(device_id)) is None: + if ( + device_entry := registry.async_get(device_id, include_composite_devices=False) + ) is None: raise HomeAssistantError("Unknown device") if ( diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index f7c5bfc32b5c96..6b91465a7e42da 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -53,7 +53,10 @@ def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str: knows the current device id, not the removed composite id. """ device_registry = dr.async_get(hass) - if device_id in device_registry.devices: + if ( + device_registry.async_get(device_id, include_composite_devices=False) + is not None + ): return device_id if not ( split_devices := device_registry.async_get_devices_for_composite_device_id( diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 4696ae371c4ca6..f30f38a9ddadc5 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -19,7 +19,6 @@ integration_platform, issue_registry as ir, ) -from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.json import ( ExtendedJSONEncoder, find_paths_unserializable_data, @@ -62,7 +61,7 @@ class DiagnosticsPlatformData: ) device_diagnostics: ( Callable[ - [HomeAssistant, ConfigEntry, DeviceEntry], + [HomeAssistant, ConfigEntry, dr.AnyDeviceEntry], Coroutine[Any, Any, Mapping[str, Any]], ] | None @@ -100,9 +99,12 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" async def async_get_device_diagnostics( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry + self, hass: HomeAssistant, config_entry: ConfigEntry, device: dr.AnyDeviceEntry ) -> Mapping[str, Any]: - """Return diagnostics for a device.""" + """Return diagnostics for a device. + + Only integrations that register child devices can receive a child device. + """ @callback @@ -314,10 +316,7 @@ async def get( if info.device_diagnostics is None: return web.Response(status=HTTPStatus.NOT_FOUND) - # A device's diagnostics may be requested for a child device, but the - # callback is currently typed for a main device. Ignoring the mismatch until - # DiagnosticsPlatformData.device_diagnostics is widened to accept AnyDeviceEntry. - data = await info.device_diagnostics(hass, config_entry, device) # type: ignore[arg-type] + data = await info.device_diagnostics(hass, config_entry, device) return await _async_get_json_file_response( hass, data, data_issues, filename, config_entry.domain, d_id, sub_id ) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 38f206d3641263..e2ce48571a8573 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1071,26 +1071,28 @@ async def _async_add_trigger_accessories(self) -> None: dev_reg = dr.async_get(self.hass) valid_device_ids = [] for device_id in self._devices: - if dev_reg.async_get(device_id, include_child_devices=False): - valid_device_ids.append(device_id) - elif dev_reg.async_get(device_id, include_main_devices=False): + device = dev_reg.async_get(device_id) + if device is None: _LOGGER.warning( ( - "HomeKit %s cannot add device %s because a child device cannot" - " be a HomeKit accessory" + "HomeKit %s cannot add device %s because it is missing from the" + " device registry" ), self._name, device_id, ) - else: + elif isinstance(device, dr.ChildDeviceEntry): _LOGGER.warning( ( - "HomeKit %s cannot add device %s because it is missing from the" - " device registry" + "HomeKit %s cannot add device %s because a child device cannot" + " be a HomeKit accessory" ), self._name, device_id, ) + else: + # A main or composite device is a valid HomeKit accessory + valid_device_ids.append(device_id) for device_id, device_triggers in ( await device_automation.async_get_device_automations( self.hass, diff --git a/homeassistant/components/lyngdorf/config_flow.py b/homeassistant/components/lyngdorf/config_flow.py index f06633f16cdd24..94366cc67b62a4 100644 --- a/homeassistant/components/lyngdorf/config_flow.py +++ b/homeassistant/components/lyngdorf/config_flow.py @@ -4,6 +4,7 @@ from typing import Any, override from urllib.parse import urlparse +from lyngdorf.const import LyngdorfModel from lyngdorf.device import ( async_find_receiver_model, async_get_device_serial, @@ -54,31 +55,26 @@ async def async_step_user( if user_input is not None: self._host = user_input[CONF_HOST] - try: - model = await async_find_receiver_model(self._host) - except TimeoutError: + model, serial = await self._async_probe(self._host) + except TimeoutConnect: errors["base"] = "timeout_connect" - except OSError: + except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - errors["base"] = "unknown" - - if not errors and not model: + except UnsupportedModel: errors["base"] = "unsupported_model" - - if not errors and model: + except CannotDetermineId: + errors["base"] = "cannot_determine_id" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: self._device_model = model.model_name self._name = model.model_name - - serial = await async_get_device_serial(self._host) - if not serial: - errors["base"] = "cannot_determine_id" - else: - self._device_serial_number = serial.lower() - await self.async_set_unique_id(self._device_serial_number) - self._abort_if_unique_id_configured() - return await self._create_entry() + self._device_serial_number = serial + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + return await self._create_entry() return self.async_show_form( step_id="user", @@ -90,6 +86,76 @@ async def async_step_user( errors=errors, ) + async def _async_probe(self, host: str) -> tuple[LyngdorfModel, str]: + """Return the model and serial of the device at a host.""" + try: + model = await async_find_receiver_model(host) + except TimeoutError as err: + raise TimeoutConnect from err + except OSError as err: + raise CannotConnect from err + if not model: + raise UnsupportedModel + + try: + serial = await async_get_device_serial(host) + except TimeoutError as err: + raise TimeoutConnect from err + except OSError as err: + raise CannotConnect from err + if not serial: + raise CannotDetermineId + + return model, serial.lower() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry. + + SSDP rediscovery only recovers a changed address while the device is + still announcing somewhere Home Assistant can hear it, which a move to + a static address or another subnet can end. + """ + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + host = user_input[CONF_HOST] + try: + model, serial = await self._async_probe(host) + except TimeoutConnect: + errors["base"] = "timeout_connect" + except CannotConnect: + errors["base"] = "cannot_connect" + except UnsupportedModel: + errors["base"] = "unsupported_model" + except CannotDetermineId: + errors["base"] = "cannot_determine_id" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_HOST: host, + CONF_MODEL: model.model_name, + CONF_SERIAL_NUMBER: serial, + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_HOST): cv.string}), + reconfigure_entry.data, + ), + errors=errors, + ) + @override async def async_step_ssdp( self, discovery_info: SsdpServiceInfo @@ -181,3 +247,19 @@ async def _async_set_info_from_discovery( raise AbortFlow("cannot_determine_id") await self.async_set_unique_id(self._device_serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" + + +class TimeoutConnect(Exception): + """Error to indicate the device did not answer in time.""" + + +class UnsupportedModel(Exception): + """Error to indicate the device is not a model we support.""" + + +class CannotDetermineId(Exception): + """Error to indicate the device did not report a serial.""" diff --git a/homeassistant/components/lyngdorf/media_player.py b/homeassistant/components/lyngdorf/media_player.py index 1c3fa481660013..7bcb955d996d1c 100644 --- a/homeassistant/components/lyngdorf/media_player.py +++ b/homeassistant/components/lyngdorf/media_player.py @@ -1,17 +1,22 @@ """Media player platform for Lyngdorf integration.""" +from datetime import datetime from typing import TYPE_CHECKING, override from lyngdorf.device import Receiver from lyngdorf.models.base import NumericRange +from lyngdorf.states import Control, PlaybackState, Repeat +from lyngdorf.streaming import NowPlaying from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, + RepeatMode, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -39,6 +44,31 @@ | MediaPlayerEntityFeature.SELECT_SOURCE ) +# The streaming module advertises transport per source and it changes at +# runtime, so these are added to FEATURES_MAIN only while the device offers +# them: AirPlay has no seek, a stopped device offers nothing at all. +CONTROL_FEATURES: tuple[tuple[Control, MediaPlayerEntityFeature], ...] = ( + (Control.PAUSE, MediaPlayerEntityFeature.PAUSE), + (Control.NEXT_TRACK, MediaPlayerEntityFeature.NEXT_TRACK), + (Control.PREVIOUS_TRACK, MediaPlayerEntityFeature.PREVIOUS_TRACK), + (Control.SEEK, MediaPlayerEntityFeature.SEEK), +) + +REPEAT_MODES: dict[Repeat, RepeatMode] = { + Repeat.OFF: RepeatMode.OFF, + Repeat.ONE: RepeatMode.ONE, + Repeat.ALL: RepeatMode.ALL, +} + +LYNGDORF_REPEATS: dict[RepeatMode, Repeat] = {v: k for k, v in REPEAT_MODES.items()} + +PLAYBACK_STATES: dict[PlaybackState, MediaPlayerState] = { + PlaybackState.PLAYING: MediaPlayerState.PLAYING, + PlaybackState.PAUSED: MediaPlayerState.PAUSED, + PlaybackState.STOPPED: MediaPlayerState.IDLE, + PlaybackState.TRANSITIONING: MediaPlayerState.BUFFERING, +} + async def async_setup_entry( hass: HomeAssistant, @@ -88,19 +118,20 @@ def __init__( device_info: DeviceInfo, translation_key: str | None, entity_id_suffix: str, - features: MediaPlayerEntityFeature = MediaPlayerEntityFeature(0), ) -> None: """Initialize the device.""" super().__init__(receiver, device_info) - assert config_entry.unique_id + if TYPE_CHECKING: + assert config_entry.unique_id self._attr_unique_id = f"{config_entry.unique_id}_{entity_id_suffix}" self._attr_translation_key = translation_key - self._attr_supported_features = features class LyngdorfZoneBDevice(LyngdorfDevice): """Lyngdorf Zone B device.""" + _attr_supported_features = FEATURES_ZONE_B + def __init__( self, receiver: Receiver, @@ -114,7 +145,6 @@ def __init__( device_info, None, "zone_b", - FEATURES_ZONE_B, ) @override @@ -214,16 +244,169 @@ def __init__( device_info, "main_zone", "main_zone", - FEATURES_MAIN, ) + @override + async def async_added_to_hass(self) -> None: + """Subscribe to position discontinuities.""" + # The jump callback fires on a seek, track change, play/pause or + # drift, rather than once a second, which is all Home Assistant + # needs: it stores a position and a timestamp and extrapolates. + await super().async_added_to_hass() + if self._has_streamer: + self.async_on_remove( + self._receiver.register_position_jump_callback(self._handle_position) + ) + + @callback + def _handle_position(self, _position_ms: int | None) -> None: + """Handle a position discontinuity.""" + self.async_write_ha_state() + + @property + def _has_streamer(self) -> bool: + """Return whether this model has a streaming module at all.""" + return self._receiver.model.has_streaming_feature() + + @property + def _now_playing(self) -> NowPlaying | None: + """Return the current track, or None if this model has no streamer.""" + if not self._has_streamer: + return None + return self._receiver.now_playing + + @override + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Return the features the device currently offers.""" + features = FEATURES_MAIN + if (now_playing := self._now_playing) is None: + return features + + for control, feature in CONTROL_FEATURES: + if control in now_playing.controls: + features |= feature + if self._receiver.can_shuffle: + features |= MediaPlayerEntityFeature.SHUFFLE_SET + if self._receiver.available_repeat_modes: + features |= MediaPlayerEntityFeature.REPEAT_SET + return features + @override @property def state(self) -> MediaPlayerState | None: """Return the state of the device.""" - if self._receiver.power_on: - return MediaPlayerState.ON - return MediaPlayerState.OFF + if not self._receiver.power_on: + return MediaPlayerState.OFF + if (now_playing := self._now_playing) is not None: + if (state := PLAYBACK_STATES.get(now_playing.state)) is not None: + return state + return MediaPlayerState.ON + + @override + @property + def media_content_type(self) -> MediaType | None: + """Return the type of media currently playing.""" + if self._now_playing is None: + return None + return MediaType.MUSIC + + @override + @property + def media_title(self) -> str | None: + """Return the title of the current track.""" + return now_playing.title if (now_playing := self._now_playing) else None + + @override + @property + def media_artist(self) -> str | None: + """Return the artist of the current track.""" + return now_playing.artist if (now_playing := self._now_playing) else None + + @override + @property + def media_album_name(self) -> str | None: + """Return the album of the current track.""" + return now_playing.album if (now_playing := self._now_playing) else None + + @override + @property + def media_image_url(self) -> str | None: + """Return the album art of the current track.""" + return now_playing.art_url if (now_playing := self._now_playing) else None + + @override + @property + def media_duration(self) -> int | None: + """Return the duration of the current track, in seconds.""" + if ( + now_playing := self._now_playing + ) is None or now_playing.duration_ms is None: + return None + return round(now_playing.duration_ms / 1000) + + @override + @property + def media_position(self) -> int | None: + """Return the position of the current track, in seconds.""" + if not self._has_streamer or not self._receiver.has_position: + return None + return round(self._receiver.position_ms / 1000) + + @override + @property + def media_position_updated_at(self) -> datetime | None: + """Return when the position was last valid.""" + if not self._has_streamer or not self._receiver.has_position: + return None + return self._receiver.position_updated_at + + @override + @property + def shuffle(self) -> bool | None: + """Return whether shuffle is enabled.""" + return self._receiver.shuffle if self._has_streamer else None + + @override + @property + def repeat(self) -> RepeatMode | None: + """Return the current repeat mode.""" + if not self._has_streamer or (repeat := self._receiver.repeat) is None: + return None + return REPEAT_MODES.get(repeat) + + @override + async def async_media_pause(self) -> None: + """Pause playback.""" + # On a controller-driven source such as AirPlay the device ends the + # session rather than pausing, and only the controlling app can + # start it again. + await self._receiver.async_pause() + + @override + async def async_media_next_track(self) -> None: + """Skip to the next track.""" + await self._receiver.async_next() + + @override + async def async_media_previous_track(self) -> None: + """Skip to the previous track.""" + await self._receiver.async_previous() + + @override + async def async_media_seek(self, position: float) -> None: + """Seek to a position, given in seconds.""" + await self._receiver.async_seek(round(position * 1000)) + + @override + async def async_set_shuffle(self, shuffle: bool) -> None: + """Enable or disable shuffle, leaving the repeat mode alone.""" + await self._receiver.async_set_shuffle(shuffle) + + @override + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set the repeat mode, leaving shuffle alone.""" + await self._receiver.async_set_repeat(LYNGDORF_REPEATS[repeat]) @override @property diff --git a/homeassistant/components/lyngdorf/quality_scale.yaml b/homeassistant/components/lyngdorf/quality_scale.yaml index 2974ff508323b1..c20a592b33a620 100644 --- a/homeassistant/components/lyngdorf/quality_scale.yaml +++ b/homeassistant/components/lyngdorf/quality_scale.yaml @@ -74,7 +74,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No repair issues needed. diff --git a/homeassistant/components/lyngdorf/strings.json b/homeassistant/components/lyngdorf/strings.json index f28652c7fe23f4..4b3baf44bc6a36 100644 --- a/homeassistant/components/lyngdorf/strings.json +++ b/homeassistant/components/lyngdorf/strings.json @@ -5,6 +5,8 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_determine_id": "[%key:component::lyngdorf::config::error::cannot_determine_id%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device at this address is a different Lyngdorf device from the one this entry was set up with.", "unsupported_model": "This Lyngdorf model is not supported" }, "error": { @@ -19,6 +21,16 @@ "confirm": { "description": "Do you want to set up **{name}**?" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::lyngdorf::config::step::user::data_description::host%]" + }, + "description": "Update the address Home Assistant uses to reach this device. It must be the same device; a different one will be rejected.", + "title": "[%key:component::lyngdorf::config::step::user::title%]" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py index ab8269d4f4bd6c..1321c9edf57e14 100644 --- a/homeassistant/components/music_assistant/helpers.py +++ b/homeassistant/components/music_assistant/helpers.py @@ -1,11 +1,11 @@ """Helpers for the Music Assistant integration.""" -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Generator +from contextlib import contextmanager import functools from typing import TYPE_CHECKING, Any -from music_assistant_models.auth import UserRole -from music_assistant_models.errors import MusicAssistantError +from music_assistant_models.errors import MusicAssistantError, UserNotFoundError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback @@ -36,6 +36,22 @@ async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: return wrapper +@contextmanager +def catch_user_not_found(username: str | None) -> Generator[None]: + """Convert a server UserNotFoundError into a translated invalid_username error.""" + if username is None: + yield + return + try: + yield + except UserNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_username", + translation_placeholders={"username": username}, + ) from err + + @callback def get_music_assistant_client( hass: HomeAssistant, config_entry_id: str @@ -47,46 +63,3 @@ def get_music_assistant_client( if entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError("Entry not loaded") return entry.runtime_data.mass - - -async def _async_get_available_mass_usernames(mass: MusicAssistantClient) -> list[str]: - """Get available Music Assistant usernames which can be used in Home Assistant.""" - users = await mass.auth.list_users() - return [ - user.username for user in users if user.enabled and user.role != UserRole.GUEST - ] - - -async def async_resolve_mass_username( - hass: HomeAssistant, mass: MusicAssistantClient, user_id: str -) -> str | None: - """Resolve the Music Assistant username for the Home Assistant user.""" - available_usernames = await _async_get_available_mass_usernames(mass) - if (user := await hass.auth.async_get_user(user_id)) is None: - return None - for cred in user.credentials: - if cred.auth_provider_type == "homeassistant": - username: str = cred.data["username"] - break - else: - return None - username = username.strip().lower() - if username in available_usernames: - return username - return None - - -async def async_verify_mass_username_availability( - mass: MusicAssistantClient, username: str -) -> None: - """Verify Music Assistant username availability for service calls.""" - available_usernames = await _async_get_available_mass_usernames(mass) - if username not in available_usernames: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_username", - translation_placeholders={ - "username": username, - "available_usernames": ", ".join(available_usernames), - }, - ) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 9baa8ea1735f38..48258e5ea9a27c 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["music_assistant"], "quality_scale": "bronze", - "requirements": ["music-assistant-client==1.4.3"], + "requirements": ["music-assistant-client==1.5.1"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 83aa62bc1b2187..0f8eb82f48a6ef 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -6,6 +6,8 @@ import os from typing import TYPE_CHECKING, Any, override +from music_assistant_client.helpers import LinkedUser +from music_assistant_models.auth import AuthProviderType from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( EventType, @@ -60,11 +62,7 @@ DOMAIN, ) from .entity import MusicAssistantEntity -from .helpers import ( - async_resolve_mass_username, - async_verify_mass_username_availability, - catch_musicassistant_error, -) +from .helpers import catch_musicassistant_error, catch_user_not_found from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item @@ -463,88 +461,95 @@ async def _async_handle_play_media( username: str | None = None, ) -> None: """Send the play_media command to the media player.""" - # An explicit username is validated strictly; when omitted we default to - # the Home Assistant user that made the call (best-effort, never raises). - user_id = self._context.user_id if self._context is not None else None - if username is not None: - await async_verify_mass_username_availability( - mass=self.mass, username=username + # An explicit username impersonates that Music Assistant user (the server rejects an + # unknown name). When omitted, default playback to the Home Assistant user that made + # the call: the server resolves them by provider link, or plays as the default + # account (required=False) when that Home Assistant user has no linked account. + user: str | LinkedUser | None = username + ha_user_id = self._context.user_id if self._context is not None else None + if username is None and ha_user_id is not None: + user = LinkedUser( + provider=AuthProviderType.HOME_ASSISTANT, + user_id=ha_user_id, + required=False, ) - elif user_id is not None: - username = await async_resolve_mass_username(self.hass, self.mass, user_id) media_uris: list[str] = [] item: MediaItemType | ItemMapping | None = None # work out (all) uri(s) to play - for media_id_str in media_id: - assert self.mass.server_info # for type checking - # pre schema 33: verify_item_uri does not exist as API method - # with schema 33: only local files have to be verified - if self.mass.server_info.schema_version < 33: - # URL or URI string - if "://" in media_id_str: - media_uris.append(media_id_str) - continue - # try content id as library id - if media_type and media_id_str.isnumeric(): - with suppress(MediaNotFoundError): - item = await self.mass.music.get_item( - MediaType(media_type), media_id_str, "library" + with catch_user_not_found(username): + for media_id_str in media_id: + assert self.mass.server_info # for type checking + # pre schema 33: verify_item_uri does not exist as API method + # with schema 33: only local files have to be verified + if self.mass.server_info.schema_version < 33: + # URL or URI string + if "://" in media_id_str: + media_uris.append(media_id_str) + continue + # try content id as library id + if media_type and media_id_str.isnumeric(): + with suppress(MediaNotFoundError): + item = await self.mass.music.get_item( + MediaType(media_type), media_id_str, "library" + ) + if ( + isinstance(item, MediaItemType | ItemMapping) + and item.uri + ): + media_uris.append(item.uri) + continue + # try local accessible filename + elif await asyncio.to_thread(os.path.isfile, media_id_str): + media_uris.append(media_id_str) + continue + else: + media_id_verify_str = media_id_str + if media_type and media_id_str.isnumeric(): + # construct in library uri as replacement for pre 33 isnumeric path + media_id_verify_str = ( + f"library://{MediaType(media_type).value}/{media_id_str}" ) - if isinstance(item, MediaItemType | ItemMapping) and item.uri: - media_uris.append(item.uri) + if await self.mass.music.verify_item_uri( + uri=media_id_verify_str, user=user + ): + media_uris.append(media_id_verify_str) continue - # try local accessible filename - elif await asyncio.to_thread(os.path.isfile, media_id_str): - media_uris.append(media_id_str) - continue - else: - media_id_verify_str = media_id_str - if media_type and media_id_str.isnumeric(): - # construct in library uri as replacement for pre 33 isnumeric path - media_id_verify_str = ( - f"library://{MediaType(media_type).value}/{media_id_str}" - ) - if await self.mass.music.verify_item_uri( - uri=media_id_verify_str, username=username + if await asyncio.to_thread(os.path.isfile, media_id_str): + media_uris.append(media_id_str) + continue + # last resort: search for media item by name/search + if item := await self.mass.music.get_item_by_name( + name=media_id_str, + artist=artist, + album=album, + media_type=MediaType(media_type) if media_type else None, + user=user, ): - media_uris.append(media_id_verify_str) - continue - if await asyncio.to_thread(os.path.isfile, media_id_str): - media_uris.append(media_id_str) - continue - # last resort: search for media item by name/search - if item := await self.mass.music.get_item_by_name( - name=media_id_str, - artist=artist, - album=album, - media_type=MediaType(media_type) if media_type else None, - username=username, - ): - if TYPE_CHECKING: - assert item.uri is not None - media_uris.append(item.uri) - - if not media_uris: - raise HomeAssistantError( - f"Could not resolve {media_id} to playable media item" - ) + if TYPE_CHECKING: + assert item.uri is not None + media_uris.append(item.uri) - # determine active queue to send the play request to - if TYPE_CHECKING: - assert self.player.active_source is not None - if queue := self.mass.player_queues.get(self.player.active_source): - queue_id = queue.queue_id - else: - queue_id = self.player_id - - await self.mass.player_queues.play_media( - queue_id, - media=media_uris, - option=self._convert_queueoption_to_media_player_enqueue(enqueue), - radio_mode=radio_mode or False, - username=username, - ) + if not media_uris: + raise HomeAssistantError( + f"Could not resolve {media_id} to playable media item" + ) + + # determine active queue to send the play request to + if TYPE_CHECKING: + assert self.player.active_source is not None + if queue := self.mass.player_queues.get(self.player.active_source): + queue_id = queue.queue_id + else: + queue_id = self.player_id + + await self.mass.player_queues.play_media( + queue_id, + media=media_uris, + option=self._convert_queueoption_to_media_player_enqueue(enqueue), + radio_mode=radio_mode or False, + user=user, + ) @catch_musicassistant_error async def _async_handle_play_announcement( diff --git a/homeassistant/components/music_assistant/services.py b/homeassistant/components/music_assistant/services.py index 9a8d1083c0610b..1bb9c1a8df5bb4 100644 --- a/homeassistant/components/music_assistant/services.py +++ b/homeassistant/components/music_assistant/services.py @@ -54,7 +54,7 @@ ATTR_USERNAME, DOMAIN, ) -from .helpers import async_verify_mass_username_availability, get_music_assistant_client +from .helpers import catch_user_not_found, get_music_assistant_client from .schemas import ( LIBRARY_RESULTS_SCHEMA, SEARCH_RESULT_SCHEMA, @@ -187,23 +187,20 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: search_artist = call.data.get(ATTR_SEARCH_ARTIST) search_album = call.data.get(ATTR_SEARCH_ALBUM) search_username = call.data.get(ATTR_USERNAME) - if search_username is not None: - await async_verify_mass_username_availability( - mass=mass, username=search_username - ) if search_album and search_artist: search_name = f"{search_artist} - {search_album} - {search_name}" elif search_album: search_name = f"{search_album} - {search_name}" elif search_artist: search_name = f"{search_artist} - {search_name}" - search_results = await mass.music.search( - search_query=search_name, - media_types=call.data.get(ATTR_MEDIA_TYPE, MediaType.ALL), - limit=call.data[ATTR_LIMIT], - library_only=call.data[ATTR_LIBRARY_ONLY], - user=search_username, - ) + with catch_user_not_found(search_username): + search_results = await mass.music.search( + search_query=search_name, + media_types=call.data.get(ATTR_MEDIA_TYPE, MediaType.ALL), + limit=call.data[ATTR_LIMIT], + library_only=call.data[ATTR_LIBRARY_ONLY], + user=search_username, + ) response: ServiceResponse = SEARCH_RESULT_SCHEMA( { ATTR_ARTISTS: [ @@ -247,8 +244,6 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: offset = call.data.get(ATTR_OFFSET, DEFAULT_OFFSET) order_by = call.data.get(ATTR_ORDER_BY, DEFAULT_SORT_ORDER) username = call.data.get(ATTR_USERNAME) - if username is not None: - await async_verify_mass_username_availability(mass=mass, username=username) base_params = { "favorite": call.data.get(ATTR_FAVORITE), "search": call.data.get(ATTR_SEARCH), @@ -266,38 +261,39 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: | list[Audiobook] | list[Podcast] ) - if media_type == MediaType.ALBUM: - library_result = await mass.music.get_library_albums( - **base_params, - album_types=call.data.get(ATTR_ALBUM_TYPE), - ) - elif media_type == MediaType.ARTIST: - library_result = await mass.music.get_library_artists( - **base_params, - album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)), - ) - elif media_type == MediaType.TRACK: - library_result = await mass.music.get_library_tracks( - **base_params, - ) - elif media_type == MediaType.RADIO: - library_result = await mass.music.get_library_radios( - **base_params, - ) - elif media_type == MediaType.PLAYLIST: - library_result = await mass.music.get_library_playlists( - **base_params, - ) - elif media_type == MediaType.AUDIOBOOK: - library_result = await mass.music.get_library_audiobooks( - **base_params, - ) - elif media_type == MediaType.PODCAST: - library_result = await mass.music.get_library_podcasts( - **base_params, - ) - else: - raise ServiceValidationError(f"Unsupported media type {media_type}") + with catch_user_not_found(username): + if media_type == MediaType.ALBUM: + library_result = await mass.music.get_library_albums( + **base_params, + album_types=call.data.get(ATTR_ALBUM_TYPE), + ) + elif media_type == MediaType.ARTIST: + library_result = await mass.music.get_library_artists( + **base_params, + album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)), + ) + elif media_type == MediaType.TRACK: + library_result = await mass.music.get_library_tracks( + **base_params, + ) + elif media_type == MediaType.RADIO: + library_result = await mass.music.get_library_radios( + **base_params, + ) + elif media_type == MediaType.PLAYLIST: + library_result = await mass.music.get_library_playlists( + **base_params, + ) + elif media_type == MediaType.AUDIOBOOK: + library_result = await mass.music.get_library_audiobooks( + **base_params, + ) + elif media_type == MediaType.PODCAST: + library_result = await mass.music.get_library_podcasts( + **base_params, + ) + else: + raise ServiceValidationError(f"Unsupported media type {media_type}") response: ServiceResponse = LIBRARY_RESULTS_SCHEMA( { diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index d7374f1ce4b797..9f228d3b70a500 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -261,7 +261,7 @@ }, "exceptions": { "invalid_username": { - "message": "The username {username} does not exist. Available usernames are {available_usernames}." + "message": "The username {username} does not exist on the Music Assistant server." } }, "issues": { diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index d0afa2d3ddfab0..56cfd69de77b4a 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -4,6 +4,14 @@ "description": "The database backup stated at {start_time} failed due to lack of resources. The backup cannot be trusted and must be restarted. This can happen if the database is too large or if the system is under heavy load. Consider upgrading the system hardware or reducing the size of the database by decreasing the number of history days to keep or creating a filter.", "title": "Database backup failed due to lack of resources" }, + "database_engine_not_supported_lts": { + "description": "Version {server_version} of {database_engine} is not a supported long-term support (LTS) release. Support for short-term releases and end-of-life LTS releases will be removed; please upgrade to one of the supported LTS versions ({lts_versions}) and restart Home Assistant to continue using the recorder.", + "title": "Update {database_engine} to a supported LTS version to continue using the recorder" + }, + "database_engine_too_old": { + "description": "Support for version {server_version} of {database_engine} is ending; the minimum supported version will be {min_version}. Please upgrade your database software and restart Home Assistant.", + "title": "Update {database_engine} to {min_version} or later to continue using the recorder" + }, "maria_db_range_index_regression": { "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB Core app, make sure to update it to the latest version.", "title": "Update MariaDB to {min_version} or later resolve a significant performance issue" diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 1cfc0a92efbf30..f5fb0111ad6bc2 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -8,7 +8,7 @@ import logging import os import time -from typing import TYPE_CHECKING, Any, Concatenate, NoReturn +from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, NoReturn from awesomeversion import ( AwesomeVersion, @@ -27,6 +27,9 @@ from homeassistant.const import WEEKDAYS from homeassistant.core import HomeAssistant, callback +from homeassistant.generated.recorder_database_versions import ( + SUPPORTED_DATABASE_VERSIONS, +) from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.recorder import ( # noqa: F401 DATA_INSTANCE, @@ -89,6 +92,47 @@ def _simple_version(version: str) -> AwesomeVersion: MIN_VERSION_PGSQL = _simple_version("12.0") MIN_VERSION_SQLITE = _simple_version("3.40.1") +# PostgreSQL has no LTS/short-term split, so we warn once the version drops +# below this upcoming minimum, as (version, breaks_in_ha_version). +UPCOMING_MIN_VERSION_PGSQL = (_simple_version("15.0"), "2027.3.0") + + +# MariaDB and MySQL ship both long-term support (LTS) releases, supported for +# years, and short-term/innovation releases, supported only until the next +# release (~3 months). We allow versions on a currently-supported (non-EoL) LTS +# series and warn against all others (short-term releases and end-of-life LTS +# series). +# Versions newer than the latest known non-LTS release are assumed supported +# so we don't warn about releases we don't know about yet. +class _LTSVersionSupport(NamedTuple): + """Supported LTS policy for an engine that ships LTS + short-term releases.""" + + supported_series: frozenset[tuple[int, int]] + latest_non_lts_series: tuple[int, int] + breaks_in_ha_version: str + + +def _parse_db_series(cycle: str) -> tuple[int, int]: + """Parse a "." release series into a tuple.""" + major, _, minor = cycle.partition(".") + return int(major), int(minor) + + +def _lts_support(engine: str, breaks_in_ha_version: str) -> _LTSVersionSupport: + """Build the LTS support policy for an engine from the generated version file.""" + versions = SUPPORTED_DATABASE_VERSIONS[engine] + return _LTSVersionSupport( + supported_series=frozenset( + _parse_db_series(cycle) for cycle in versions["supported_lts"] + ), + latest_non_lts_series=_parse_db_series(versions["latest_non_lts"]), + breaks_in_ha_version=breaks_in_ha_version, + ) + + +SUPPORTED_MARIA_DB_LTS = _lts_support("mariadb", "2027.3.0") +SUPPORTED_MYSQL_LTS = _lts_support("mysql", "2027.3.0") + # This is the maximum time after the recorder ends the session # before we no longer consider startup to be a "restart" and we @@ -346,6 +390,122 @@ def _raise_if_version_unsupported( raise UnsupportedDialect +@callback +def _async_delete_issue_deprecated_version(hass: HomeAssistant, issue_id: str) -> None: + """Delete a deprecated database version repair issue.""" + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +@callback +def _async_create_issue_deprecated_version( + hass: HomeAssistant, + server_version: AwesomeVersion, + database_engine: str, + min_version: AwesomeVersion, + breaks_in_ha_version: str, +) -> None: + """Warn about upcoming unsupported database version.""" + ir.async_create_issue( + hass, + DOMAIN, + "database_engine_too_old", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="database_engine_too_old", + translation_placeholders={ + "database_engine": database_engine, + "server_version": str(server_version), + "min_version": str(min_version), + }, + breaks_in_ha_version=breaks_in_ha_version, + ) + + +@callback +def _async_create_issue_not_supported_lts( + hass: HomeAssistant, + server_version: AwesomeVersion, + database_engine: str, + lts_versions: str, + breaks_in_ha_version: str, +) -> None: + """Warn about a database version that is not a supported LTS release.""" + ir.async_create_issue( + hass, + DOMAIN, + "database_engine_not_supported_lts", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="database_engine_not_supported_lts", + translation_placeholders={ + "database_engine": database_engine, + "server_version": str(server_version), + "lts_versions": lts_versions, + }, + breaks_in_ha_version=breaks_in_ha_version, + ) + + +def _check_deprecated_version( + hass: HomeAssistant, + server_version: AwesomeVersion, + database_engine: str, + upcoming_min_version: tuple[AwesomeVersion, str], +) -> None: + """Create or remove the issue about an upcoming unsupported database version.""" + min_version, breaks_in_ha_version = upcoming_min_version + if server_version < min_version: + hass.add_job( + _async_create_issue_deprecated_version, + hass, + server_version, + database_engine, + min_version, + breaks_in_ha_version, + ) + else: + hass.add_job( + _async_delete_issue_deprecated_version, hass, "database_engine_too_old" + ) + + +def _check_lts_version( + hass: HomeAssistant, + server_version: AwesomeVersion, + database_engine: str, + lts_support: _LTSVersionSupport, +) -> None: + """Warn unless the version is on a supported LTS series or newer than we know. + + MariaDB and MySQL only support long-term support (LTS) releases for years; + short-term releases and end-of-life LTS series are deprecated. Versions newer + than the latest known non-LTS release are assumed supported to avoid warning + about releases we don't know about yet. + """ + series = (server_version.section(0), server_version.section(1)) + if ( + series in lts_support.supported_series + or series > lts_support.latest_non_lts_series + ): + hass.add_job( + _async_delete_issue_deprecated_version, + hass, + "database_engine_not_supported_lts", + ) + else: + lts_versions = ", ".join( + f"{major}.{minor}" for major, minor in sorted(lts_support.supported_series) + ) + hass.add_job( + _async_create_issue_not_supported_lts, + hass, + server_version, + database_engine, + lts_versions, + lts_support.breaks_in_ha_version, + ) + + def _extract_version_from_server_response_or_raise( server_response: str, ) -> AwesomeVersion: @@ -490,6 +650,10 @@ def setup_connection_for_dialect( _raise_if_version_unsupported( version or version_string, "MariaDB", MIN_VERSION_MARIA_DB ) + # No elif here since _raise_if_version_unsupported raises + _check_lts_version( + instance.hass, version, "MariaDB", SUPPORTED_MARIA_DB_LTS + ) if version and ( (version < RECOMMENDED_MIN_VERSION_MARIA_DB) or (MARIA_DB_106 <= version < RECOMMENDED_MIN_VERSION_MARIA_DB_106) @@ -516,6 +680,7 @@ def setup_connection_for_dialect( # MySQL # https://github.com/home-assistant/core/issues/137178 slow_dependent_subquery = True + _check_lts_version(instance.hass, version, "MySQL", SUPPORTED_MYSQL_LTS) # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") @@ -535,6 +700,10 @@ def setup_connection_for_dialect( _raise_if_version_unsupported( version or version_string, "PostgreSQL", MIN_VERSION_PGSQL ) + # No elif here since _raise_if_version_unsupported raises + _check_deprecated_version( + instance.hass, version, "PostgreSQL", UPCOMING_MIN_VERSION_PGSQL + ) else: _fail_unsupported_dialect(dialect_name) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 650579aaacc809..57e5b3c0507163 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -83,6 +83,7 @@ PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CAMERA, Platform.CLIMATE, Platform.COVER, Platform.EVENT, diff --git a/homeassistant/components/shelly/camera.py b/homeassistant/components/shelly/camera.py new file mode 100644 index 00000000000000..5d19c330e75f6e --- /dev/null +++ b/homeassistant/components/shelly/camera.py @@ -0,0 +1,139 @@ +"""Support for Shelly cameras.""" + +from dataclasses import dataclass +from typing import Final, override +from urllib.parse import quote + +from homeassistant.components.camera import ( + Camera, + CameraEntityDescription, + CameraEntityFeature, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator +from .entity import ( + RpcEntityDescription, + ShellyRpcAttributeEntity, + async_setup_entry_rpc, +) +from .utils import get_host + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RpcCameraEntityDescription(RpcEntityDescription, CameraEntityDescription): + """Class to describe a Shelly RPC camera entity.""" + + stream: int + + +RPC_CAMERA_ENTITIES: Final = { + "stream_0": RpcCameraEntityDescription( + key="camera", + stream=0, + translation_key="stream", + translation_placeholders={"stream_id": "0"}, + ), + "stream_1": RpcCameraEntityDescription( + key="camera", + stream=1, + translation_key="stream", + translation_placeholders={"stream_id": "1"}, + entity_registry_enabled_default=False, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ShellyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Shelly camera entities.""" + if not config_entry.runtime_data.rpc: + return + + async_setup_entry_rpc( + hass, + config_entry, + async_add_entities, + RPC_CAMERA_ENTITIES, + ShellyCameraEntity, + ) + + +class ShellyCameraEntity(ShellyRpcAttributeEntity, Camera): + """Shelly camera entity for RPC devices.""" + + _attr_brand = "Shelly" + _attr_supported_features = CameraEntityFeature.STREAM + entity_description: RpcCameraEntityDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcCameraEntityDescription, + ) -> None: + """Initialize Shelly camera entity.""" + super().__init__(coordinator, key, attribute, description) + Camera.__init__(self) + + self._attr_model = self.coordinator.model + + @override + @property + def available(self) -> bool: + """Available.""" + available = super().available + if not available: + return False + + config = self.coordinator.device.config[self.key] + return not self.status["privacy"] and config["rtsp"]["enable"] + + @override + @property + def is_on(self) -> bool: + """Return True if the camera is running.""" + return ( + self.coordinator.device.initialized and self.status["streamer"] == "running" + ) + + @override + @property + def is_recording(self) -> bool: + """Return True if the camera is currently recording.""" + return bool(self.status.get("recordings")) + + @override + @property + def is_streaming(self) -> bool: + """Return True if the camera is currently streaming.""" + return bool(self.status["streams"] > 0) + + @override + async def stream_source(self) -> str | None: + """Return the RTSP stream source for go2rtc.""" + username = self.coordinator.config_entry.data.get(CONF_USERNAME) + password = self.coordinator.config_entry.data.get(CONF_PASSWORD) + host = get_host(self.coordinator.config_entry.data[CONF_HOST]) + + if username and password: + return ( + f"rtsp://{quote(username, safe='')}:{quote(password, safe='')}@{host}" + f"/stream/{self.entity_description.stream}" + ) + + return f"rtsp://{host}/stream/{self.entity_description.stream}" + + @override + @property + def use_stream_for_stills(self) -> bool: + """Use the RTSP stream to generate still images.""" + return True diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index f12ddea711b7c5..573c5e16fec55e 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -70,6 +70,13 @@ } }, "switch": { + "camera_privacy": { + "default": "mdi:eye-outline", + "state": { + "off": "mdi:eye-outline", + "on": "mdi:eye-off-outline" + } + }, "cury_away_mode": { "default": "mdi:home-outline", "state": { diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index cf6d25176cef05..f9a6ce2f14804f 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -263,6 +263,11 @@ "name": "Unmute alarm" } }, + "camera": { + "stream": { + "name": "Stream {stream_id}" + } + }, "climate": { "thermostat": { "state_attributes": { @@ -576,6 +581,9 @@ } }, "switch": { + "camera_privacy": { + "name": "Privacy" + }, "charging": { "name": "Charging" }, diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 5d7ef241b4dcc7..452ea2442738d4 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -423,6 +423,16 @@ def __init__( method_off="cury_set_away_mode", method_params_fn=lambda id, value: (id, value), ), + "camera_privacy": RpcSwitchDescription( + key="camera", + sub_key="privacy", + translation_key="camera_privacy", + is_on=lambda status: status["privacy"], + method_on="set_camera_privacy", + method_off="set_camera_privacy", + method_params_fn=lambda id, value: (id, value), + entity_category=EntityCategory.CONFIG, + ), } diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 2052ab8da8da68..99225a0042656f 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -102,8 +102,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_all_devices=True, ) - if device_id is not None and dr.async_get(hass).async_is_composite_device_id( - device_id + device_registry = dr.async_get(hass) + if ( + device_id is not None + and device_registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is not None ): # The device was split into one device per config entry; ask the user to # select a device again diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 85d19f013b1f2d..a2020a30a77c9d 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -89,11 +89,11 @@ def __init__( device_registry = dr.async_get(hass) # Allow linking to a main or child device, but not to a composite device. - if ( - (device_id := config.get(CONF_DEVICE_ID)) is not None - and (device_entry := device_registry.async_get(device_id)) is not None - and not device_registry.async_is_composite_device_id(device_id) - ): + if (device_id := config.get(CONF_DEVICE_ID)) is not None and ( + device_entry := device_registry.async_get( + device_id, include_composite_devices=False + ) + ) is not None: self.device_entry = device_entry @property diff --git a/homeassistant/components/template/repairs.py b/homeassistant/components/template/repairs.py index c7e308eee879b7..3854c36a458d85 100644 --- a/homeassistant/components/template/repairs.py +++ b/homeassistant/components/template/repairs.py @@ -41,9 +41,13 @@ async def async_step_select_device( errors: dict[str, str] = {} if user_input is not None: device_id = user_input.get(CONF_DEVICE_ID) - if device_id is None or ( - device_registry.async_get(device_id) is not None - and not device_registry.async_is_composite_device_id(device_id) + if ( + device_id is None + or device_registry.async_get( + device_id, + include_composite_devices=False, + ) + is not None ): options = {**entry.options} if device_id: diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index c735db8ced5788..0f95516be25059 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -45,6 +45,6 @@ "loggers": ["tuya_sharing"], "requirements": [ "tuya-device-handlers==0.0.26", - "tuya-device-sharing-sdk==0.2.14" + "tuya-device-sharing-sdk==0.2.15" ] } diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 4beebfe448c92f..c1315d8619cc04 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -1728,13 +1728,21 @@ async def async_step_esphome( discovered_home_id = ( str(discovery_info.zwave_home_id) if discovery_info.zwave_home_id else None ) + addon_entries = [ + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.data.get(CONF_USE_ADDON) + ] + if discovered_home_id is None and any( + entry.data.get(CONF_SOCKET_PATH) == discovery_info.socket_path + for entry in addon_entries + ): + # A reconnect of the configured adapter without a home ID is the + # same adapter, not a new one to migrate to. + return self.async_abort(reason="already_configured") + if addon_entry := next( - ( - entry - for entry in self._async_current_entries(include_ignore=False) - if entry.data.get(CONF_USE_ADDON) - and entry.unique_id != discovered_home_id - ), + (entry for entry in addon_entries if entry.unique_id != discovered_home_id), None, ): self._reconfigure_config_entry = addon_entry diff --git a/homeassistant/components/zwave_js/logbook.py b/homeassistant/components/zwave_js/logbook.py index 2db0600fd9bbb3..97b7468c724598 100644 --- a/homeassistant/components/zwave_js/logbook.py +++ b/homeassistant/components/zwave_js/logbook.py @@ -37,10 +37,10 @@ def async_describe_zwave_js_notification_event( event: Event, ) -> dict[str, str]: """Describe Z-Wave JS notification event.""" - device = dev_reg.devices[event.data[ATTR_DEVICE_ID]] - # Z-Wave JS devices always have a name - device_name = device.name_by_user or device.name - assert device_name + device = dev_reg.async_get( + event.data[ATTR_DEVICE_ID], include_child_devices=False + ) + device_name = (device.name_by_user or device.name or "") if device else "" command_class = event.data[ATTR_COMMAND_CLASS] command_class_name = event.data[ATTR_COMMAND_CLASS_NAME] @@ -84,10 +84,10 @@ def async_describe_zwave_js_value_notification_event( event: Event, ) -> dict[str, str]: """Describe Z-Wave JS value notification event.""" - device = dev_reg.devices[event.data[ATTR_DEVICE_ID]] - # Z-Wave JS devices always have a name - device_name = device.name_by_user or device.name - assert device_name + device = dev_reg.async_get( + event.data[ATTR_DEVICE_ID], include_child_devices=False + ) + device_name = (device.name_by_user or device.name or "") if device else "" command_class = event.data[ATTR_COMMAND_CLASS_NAME] label = event.data[ATTR_LABEL] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4f2eda4b5bc219..5f3f8ae39a70d2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -532,6 +532,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "ariston": { + "name": "Ariston", + "integration_type": "virtual", + "supported_by": "midea" + }, "arris_tg2492lg": { "name": "Arris TG2492LG", "integration_type": "hub", diff --git a/homeassistant/generated/recorder_database_versions.py b/homeassistant/generated/recorder_database_versions.py new file mode 100644 index 00000000000000..5ff52aa34919f5 --- /dev/null +++ b/homeassistant/generated/recorder_database_versions.py @@ -0,0 +1,30 @@ +"""Automatically generated file. + +To update, run python3 -m script.gen_recorder_db_versions + +This file is generated from https://endoflife.date. For each of MariaDB and +MySQL, ``supported_lts`` lists the currently supported (non-end-of-life) +long-term support release series, and ``latest_non_lts`` is the newest known +short-term/innovation release series. Both are ``"."`` strings. +""" + +from typing import TypedDict + + +class DatabaseVersions(TypedDict): + """Supported release series for a database engine.""" + + supported_lts: list[str] + latest_non_lts: str + + +SUPPORTED_DATABASE_VERSIONS: dict[str, DatabaseVersions] = { + "mariadb": { + "supported_lts": ["10.11", "11.4", "11.8", "12.3"], + "latest_non_lts": "12.2", + }, + "mysql": { + "supported_lts": ["8.4", "9.7"], + "latest_non_lts": "9.6", + }, +} diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 53f07eefd82a29..9ffb5c842bb38f 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1743,18 +1743,20 @@ def async_get( self, device_id: str, *, - include_child_devices: Literal[True] = True, - include_main_devices: Literal[True] = True, - ) -> AnyDeviceEntry | None: ... + include_child_devices: Literal[False], + include_main_devices: bool = True, + include_composite_devices: bool = True, + ) -> DeviceEntry | None: ... @overload def async_get( self, device_id: str, *, - include_child_devices: Literal[False], - include_main_devices: Literal[True] = True, - ) -> DeviceEntry | None: ... + include_child_devices: Literal[True] = True, + include_main_devices: Literal[False], + include_composite_devices: Literal[False], + ) -> ChildDeviceEntry | None: ... @overload def async_get( @@ -1763,7 +1765,18 @@ def async_get( *, include_child_devices: Literal[True] = True, include_main_devices: Literal[False], - ) -> ChildDeviceEntry | None: ... + include_composite_devices: Literal[True] = True, + ) -> AnyDeviceEntry | None: ... + + @overload + def async_get( + self, + device_id: str, + *, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[True] = True, + include_composite_devices: bool = True, + ) -> AnyDeviceEntry | None: ... @callback def async_get( @@ -1772,6 +1785,7 @@ def async_get( *, include_child_devices: bool = True, include_main_devices: bool = True, + include_composite_devices: bool = True, ) -> AnyDeviceEntry | None: """Get device or child device. @@ -1786,8 +1800,8 @@ def async_get( With include_child_devices=False a child-device id resolves to None (the child is treated as absent) and the return type excludes children. With - include_main_devices=False a main-device id (including a composite) resolves to - None and the return type excludes main devices. + include_main_devices=False a main-device id resolves to None. With + include_composite_devices=False a composite-device id resolves to None. """ if ( include_main_devices @@ -1799,7 +1813,7 @@ def async_get( and (child_device := self._child_device_data.get(device_id)) is not None ): return child_device - if include_main_devices and ( + if include_composite_devices and ( split_devices := self.devices.get_devices_for_composite_device_id(device_id) ): return self._restore_composite_device(device_id, split_devices) @@ -2021,6 +2035,15 @@ def async_is_composite_device_id(self, device_id: str) -> bool | None: composite device id no longer refers to a registered device. Returns False for a registered device id, and None for an unknown id. """ + report_usage( + "calls `device_registry.async_is_composite_device_id`, which is " + "deprecated; use `async_get` with `include_composite_devices=False` " + "instead - a composite device id resolves with `async_get(device_id)` but " + "not with `async_get(device_id, include_composite_devices=False)`", + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + breaks_in_ha_version="2027.9.0", + ) if device_id in self.devices: return False if self.devices.get_devices_for_composite_device_id(device_id): @@ -2949,8 +2972,7 @@ def _async_update_device( # noqa: C901 if ( via_device_id is not UNDEFINED and via_device_id is not None - and via_device_id not in self.devices - and not self.devices.get_devices_for_composite_device_id(via_device_id) + and self.async_get(via_device_id, include_child_devices=False) is None ): if via_device_id in self._child_device_data: raise HomeAssistantError( diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b411623ced37ed..1d9f4dce834140 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1168,8 +1168,8 @@ def _validate_item( if device_id and device_id is not UNDEFINED: device_registry = dr.async_get(hass) if ( - device_id not in device_registry.devices - and device_id not in device_registry.child_devices + device_registry.async_get(device_id, include_composite_devices=False) + is None ): raise ValueError(f"Device {device_id} does not exist") if ( @@ -1815,7 +1815,12 @@ def _ignore_composite_device_id( if not device_id or device_id is UNDEFINED: return device_id device_registry = dr.async_get(self.hass) - if not device_registry.async_is_composite_device_id(device_id): + if ( + device_registry.async_get( + device_id, include_main_devices=False, include_child_devices=False + ) + is None + ): # A real device or an unknown id; let _validate_item handle it return device_id report_issue = async_suggest_report_issue( @@ -2176,13 +2181,10 @@ def _split_device_id( config_subentry_id: str | None, ) -> str | None: """Map a device id to the split device matching the entity's config entry.""" - # Note: check container membership, not async_get, which returns a restored - # composite for a composite device id. Child devices are their own container - # and are never composites, so an entity on one keeps its device id. if ( device_id is None - or device_id in device_registry.devices - or device_id in device_registry.child_devices + or device_registry.async_get(device_id, include_composite_devices=False) + is not None ): return device_id successors = device_registry.async_get_devices_for_composite_device_id( diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index c6ceb6508f6876..1aaeaf75f584a0 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -172,7 +172,7 @@ def async_remove_helper_devices( if source_device_id is not None else None ) - if source_device is None: + if source_device_id is None or source_device is None: # No source device (gone, or none selected). In remove-all mode the helper's devices # are still removed, leaving its entities without a device; targeted mode has no # duplicate to match. @@ -190,8 +190,8 @@ def async_remove_helper_devices( # synthesized composite) or a concrete device - a main device or a child device. A main # device's splits, if any, share this id as their composite_device_id. source_is_concrete = ( - source_device_id in device_registry.devices - or source_device_id in device_registry.child_devices + device_registry.async_get(source_device_id, include_composite_devices=False) + is not None ) composite_device_id = ( ( diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 192226e7a33c13..cb878df223a5c3 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -430,8 +430,8 @@ async def async_extract_config_entry_ids( # Some devices may have no entities for device_id in referenced.referenced_devices: - if (device_id in dev_reg.devices or device_id in dev_reg.child_devices) and ( - device := dev_reg.async_get(device_id) + if ( + device := dev_reg.async_get(device_id, include_composite_devices=False) ) is not None: config_entry_ids.update(device.config_entries) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 348b2da7bd94e0..bd9c062c03a320 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -161,15 +161,11 @@ def _resolve_referenced_devices( ) -> None: """Resolve targeted device ids into referenced device ids.""" for device_id in device_ids: - if device_id in dev_reg.devices: + device = dev_reg.async_get(device_id) + if device is None: + selected.missing_devices.add(device_id) selected.referenced_devices.add(device_id) - selected.referenced_devices.update( - child_device.id - for child_device in dev_reg.child_devices.get_children_for_device_id( - device_id - ) - ) - elif device_id in dev_reg.child_devices: + elif isinstance(device, dr.ChildDeviceEntry): selected.referenced_devices.add(device_id) elif split_devices := dev_reg.async_get_devices_for_composite_device_id( device_id @@ -190,8 +186,13 @@ def _resolve_referenced_devices( ) ) else: - selected.missing_devices.add(device_id) selected.referenced_devices.add(device_id) + selected.referenced_devices.update( + child_device.id + for child_device in dev_reg.child_devices.get_children_for_device_id( + device_id + ) + ) def async_extract_referenced_entity_ids( diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d015c15709a7e5..d5de9f1085a6cd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ fnv-hash-fast==2.0.3 go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 habluetooth==6.26.7 -hass-nabucasa==2.2.0 +hass-nabucasa==2.3.0 hassil==3.11.0 home-assistant-bluetooth==2.0.0 home-assistant-frontend==20260729.7 diff --git a/pyproject.toml b/pyproject.toml index fd9805928ba253..83c8c5041738f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==2.0.3", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==2.2.0", + "hass-nabucasa==2.3.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 4b7543a0d0076c..4cf011f5edb71b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ cronsim==2.7 cryptography==48.0.1 fnv-hash-fast==2.0.3 ha-ffmpeg==3.2.2 -hass-nabucasa==2.2.0 +hass-nabucasa==2.3.0 hassil==3.11.0 home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.7.30 diff --git a/requirements_all.txt b/requirements_all.txt index f15f25be3eea81..1d40a6aa651f54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -133,7 +133,7 @@ WSDiscovery==2.1.2 accuweather==5.1.0 # homeassistant.components.actron_air -actron-neo-api==0.5.13 +actron-neo-api==0.5.14 # homeassistant.components.adax adax==0.4.0 @@ -1237,7 +1237,7 @@ hanna-cloud==0.0.7 harbor-python==1.5.0 # homeassistant.components.cloud -hass-nabucasa==2.2.0 +hass-nabucasa==2.3.0 # homeassistant.components.splunk hass-splunk==0.1.4 @@ -1640,7 +1640,7 @@ mozart-api==6.2.0.44.0 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.4.3 +music-assistant-client==1.5.1 # homeassistant.components.tts mutagen==1.48.1 @@ -3250,7 +3250,7 @@ ttn_client==1.3.0 tuya-device-handlers==0.0.26 # homeassistant.components.tuya -tuya-device-sharing-sdk==0.2.14 +tuya-device-sharing-sdk==0.2.15 # homeassistant.components.twentemilieu twentemilieu==3.0.0 diff --git a/script/gen_recorder_db_versions.py b/script/gen_recorder_db_versions.py new file mode 100644 index 00000000000000..ecd358e925fc20 --- /dev/null +++ b/script/gen_recorder_db_versions.py @@ -0,0 +1,166 @@ +"""Generate the recorder's supported database versions file from endoflife.date. + +Usage: + python3 -m script.gen_recorder_db_versions # regenerate the file + python3 -m script.gen_recorder_db_versions validate # fail if out of date + +For MariaDB and MySQL we track the currently supported (non-end-of-life) LTS +release series and the newest known short-term/innovation release series. + +A CI job on the dev branch runs the ``validate`` mode, so it fails whenever a new +(non-patch) MariaDB or MySQL release means the committed file is out of date. + +Accessing the network here is a deliberate exception to the general policy of +not doing so in tests/CI; only this maintenance job talks to endoflife.date, and +the recorder itself only reads the committed file. ``validate`` skips (instead of +failing) when endoflife.date cannot be reached, so an outage does not fail +unrelated pull requests. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +import importlib.util +import json +from pathlib import Path +import sys +import time +import urllib.error +import urllib.request + +SOURCES = { + "mariadb": "https://endoflife.date/api/mariadb.json", + "mysql": "https://endoflife.date/api/mysql.json", +} +FETCH_TIMEOUT = 30 +FETCH_RETRIES = 3 +FETCH_RETRY_WAIT = 2 +# Errors that mean we could not get usable data from endoflife.date +FETCH_ERRORS = (urllib.error.URLError, TimeoutError, json.JSONDecodeError) +OUTPUT_FILE = ( + Path(__file__).parent.parent + / "homeassistant" + / "generated" + / "recorder_database_versions.py" +) +HEADER = '''"""Automatically generated file. + +To update, run python3 -m script.gen_recorder_db_versions + +This file is generated from https://endoflife.date. For each of MariaDB and +MySQL, ``supported_lts`` lists the currently supported (non-end-of-life) +long-term support release series, and ``latest_non_lts`` is the newest known +short-term/innovation release series. Both are ``"."`` strings. +""" + +from typing import TypedDict + + +class DatabaseVersions(TypedDict): + """Supported release series for a database engine.""" + + supported_lts: list[str] + latest_non_lts: str + + +SUPPORTED_DATABASE_VERSIONS: dict[str, DatabaseVersions] = {''' + + +def _series_key(cycle: str) -> tuple[int, int]: + """Return a sortable (major, minor) key for a "." cycle.""" + major, _, minor = cycle.partition(".") + return int(major), int(minor) + + +def _eol(cycle: dict) -> date: + """Return the end-of-life date for a cycle (date.max when none is set).""" + eol = cycle["eol"] + return date.max if isinstance(eol, bool) else date.fromisoformat(eol) + + +def _engine_versions(cycles: list[dict], today: date) -> dict: + """Compute the supported LTS series and latest non-LTS series for an engine.""" + supported_lts = sorted( + ( + cycle["cycle"] + for cycle in cycles + if cycle.get("lts") and _eol(cycle) > today + ), + key=_series_key, + ) + latest_non_lts = max( + (cycle["cycle"] for cycle in cycles if not cycle.get("lts")), + key=_series_key, + ) + return {"supported_lts": supported_lts, "latest_non_lts": latest_non_lts} + + +def _fetch(url: str) -> list[dict]: + """Fetch and parse an endoflife.date API response, retrying transient errors.""" + for attempt in range(FETCH_RETRIES): + try: + with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT) as response: + cycles: list[dict] = json.load(response) + return cycles + except FETCH_ERRORS: + if attempt == FETCH_RETRIES - 1: + raise + time.sleep(FETCH_RETRY_WAIT) + raise RuntimeError # pragma: no cover + + +def fetch_versions() -> dict: + """Fetch and compute the supported version data for all engines.""" + today = datetime.now(UTC).date() + return { + engine: _engine_versions(_fetch(url), today) for engine, url in SOURCES.items() + } + + +def render(versions: dict) -> str: + """Render the generated recorder_database_versions.py content.""" + lines = [HEADER] + for engine, data in versions.items(): + supported = ", ".join(f'"{cycle}"' for cycle in data["supported_lts"]) + lines.append(f' "{engine}": {{') + lines.append(f' "supported_lts": [{supported}],') + lines.append(f' "latest_non_lts": "{data["latest_non_lts"]}",') + lines.append(" },") + lines.append("}") + return "\n".join(lines) + "\n" + + +def load_committed() -> dict: + """Load the committed SUPPORTED_DATABASE_VERSIONS without importing recorder.""" + spec = importlib.util.spec_from_file_location("_database_versions", OUTPUT_FILE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + committed: dict = module.SUPPORTED_DATABASE_VERSIONS + return committed + + +def main() -> int: + """Generate the file or validate that the committed one is up to date.""" + if len(sys.argv) > 1 and sys.argv[1] == "validate": + try: + versions = fetch_versions() + except FETCH_ERRORS as err: + print(f"Skipping validation, could not reach endoflife.date: {err}") + return 0 + if versions != load_committed(): + relative_path = OUTPUT_FILE.relative_to(Path(__file__).parent.parent) + print( + f"{relative_path} is out of date with the latest MariaDB or MySQL " + "release data from endoflife.date (a new release or an LTS series " + "reaching end of life).\n" + "Run: python3 -m script.gen_recorder_db_versions" + ) + return 1 + return 0 + OUTPUT_FILE.write_text(render(fetch_versions())) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index 62822db4d2e516..0b8ff8ad3c0e70 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -41,9 +41,14 @@ async def test_invalid_api_key( ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_CONFIG ) assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} @@ -58,9 +63,14 @@ async def test_api_error( ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_CONFIG ) assert result["errors"] == {"base": "cannot_connect"} @@ -75,9 +85,14 @@ async def test_requests_exceeded_error( ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_CONFIG ) assert result["errors"] == {CONF_API_KEY: "requests_exceeded"} @@ -94,9 +109,14 @@ async def test_integration_already_exists( ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_CONFIG ) assert result["type"] is FlowResultType.ABORT @@ -108,9 +128,14 @@ async def test_create_entry( ) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_CONFIG ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/airly/__init__.py b/tests/components/airly/__init__.py index 199c7a26870220..05d742603da6b7 100644 --- a/tests/components/airly/__init__.py +++ b/tests/components/airly/__init__.py @@ -6,9 +6,9 @@ from tests.common import MockConfigEntry, async_load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -API_NEAREST_URL = "https://airapi.airly.eu/v2/measurements/nearest?lat=123.000000&lng=456.000000&maxDistanceKM=5.000000" +API_NEAREST_URL = "https://airapi.airly.eu/v2/measurements/nearest?lat=12.300000&lng=45.600000&maxDistanceKM=5.000000" API_POINT_URL = ( - "https://airapi.airly.eu/v2/measurements/point?lat=123.000000&lng=456.000000" + "https://airapi.airly.eu/v2/measurements/point?lat=12.300000&lng=45.600000" ) HEADERS = { "X-RateLimit-Limit-day": "100", @@ -24,11 +24,11 @@ async def init_integration( domain=DOMAIN, title="Home", entry_id="3bd2acb0e4f0476d40865546d0d91921", - unique_id="123-456", + unique_id="12.3-45.6", data={ "api_key": "foo", - "latitude": 123, - "longitude": 456, + "latitude": 12.3, + "longitude": 45.6, }, ) diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr index ea5a82da6402d9..74a0d6a29788a6 100644 --- a/tests/components/airly/snapshots/test_sensor.ambr +++ b/tests/components/airly/snapshots/test_sensor.ambr @@ -37,7 +37,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-co', + 'unique_id': '12.3-45.6-co', 'unit_of_measurement': , }) # --- @@ -96,7 +96,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'caqi', - 'unique_id': '123-456-caqi', + 'unique_id': '12.3-45.6-caqi', 'unit_of_measurement': 'CAQI', }) # --- @@ -156,7 +156,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-humidity', + 'unique_id': '12.3-45.6-humidity', 'unit_of_measurement': , }) # --- @@ -215,7 +215,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-no2', + 'unique_id': '12.3-45.6-no2', 'unit_of_measurement': , }) # --- @@ -276,7 +276,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-o3', + 'unique_id': '12.3-45.6-o3', 'unit_of_measurement': , }) # --- @@ -337,7 +337,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-pm1', + 'unique_id': '12.3-45.6-pm1', 'unit_of_measurement': , }) # --- @@ -396,7 +396,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-pm10', + 'unique_id': '12.3-45.6-pm10', 'unit_of_measurement': , }) # --- @@ -457,7 +457,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-pm25', + 'unique_id': '12.3-45.6-pm25', 'unit_of_measurement': , }) # --- @@ -518,7 +518,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-pressure', + 'unique_id': '12.3-45.6-pressure', 'unit_of_measurement': , }) # --- @@ -577,7 +577,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-so2', + 'unique_id': '12.3-45.6-so2', 'unit_of_measurement': , }) # --- @@ -638,7 +638,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123-456-temperature', + 'unique_id': '12.3-45.6-temperature', 'unit_of_measurement': , }) # --- diff --git a/tests/components/airly/test_config_flow.py b/tests/components/airly/test_config_flow.py index f6687f787492fa..6da67ebf321f2e 100644 --- a/tests/components/airly/test_config_flow.py +++ b/tests/components/airly/test_config_flow.py @@ -17,8 +17,8 @@ CONFIG = { CONF_API_KEY: "foo", - CONF_LATITUDE: 123, - CONF_LONGITUDE: 456, + CONF_LATITUDE: 12.3, + CONF_LONGITUDE: 45.6, } @@ -44,7 +44,14 @@ async def test_invalid_api_key( ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG ) assert result["errors"] == {"base": "invalid_api_key"} @@ -64,7 +71,14 @@ async def test_invalid_location( ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG ) assert result["errors"] == {"base": "wrong_location"} @@ -85,7 +99,14 @@ async def test_invalid_location_for_point_and_nearest( with patch("homeassistant.components.airly.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG ) assert result["type"] is FlowResultType.ABORT @@ -99,10 +120,17 @@ async def test_duplicate_error( aioclient_mock.get( API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) ) - MockConfigEntry(domain=DOMAIN, unique_id="123-456", data=CONFIG).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, unique_id="12.3-45.6", data=CONFIG).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG ) assert result["type"] is FlowResultType.ABORT @@ -119,7 +147,14 @@ async def test_create_entry( with patch("homeassistant.components.airly.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -146,7 +181,14 @@ async def test_create_entry_with_nearest_method( with patch("homeassistant.components.airly.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index da606d718a3b62..8058f751c787a1 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -38,11 +38,11 @@ async def test_config_not_ready( entry = MockConfigEntry( domain=DOMAIN, title="Home", - unique_id="123-456", + unique_id="12.3-45.6", data={ "api_key": "foo", - "latitude": 123, - "longitude": 456, + "latitude": 12.3, + "longitude": 45.6, "use_nearest": True, }, ) @@ -62,8 +62,8 @@ async def test_config_without_unique_id( title="Home", data={ "api_key": "foo", - "latitude": 123, - "longitude": 456, + "latitude": 12.3, + "longitude": 45.6, }, ) @@ -73,7 +73,7 @@ async def test_config_without_unique_id( entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.LOADED - assert entry.unique_id == "123-456" + assert entry.unique_id == "12.3-45.6" async def test_config_with_turned_off_station( @@ -83,11 +83,11 @@ async def test_config_with_turned_off_station( entry = MockConfigEntry( domain=DOMAIN, title="Home", - unique_id="123-456", + unique_id="12.3-45.6", data={ "api_key": "foo", - "latitude": 123, - "longitude": 456, + "latitude": 12.3, + "longitude": 45.6, }, ) @@ -114,11 +114,11 @@ async def test_update_interval( entry = MockConfigEntry( domain=DOMAIN, title="Home", - unique_id="123-456", + unique_id="12.3-45.6", data={ "api_key": "foo", - "latitude": 123, - "longitude": 456, + "latitude": 12.3, + "longitude": 45.6, }, ) @@ -241,7 +241,7 @@ async def test_remove_air_quality_entities( entity_registry.async_get_or_create( AIR_QUALITY_DOMAIN, DOMAIN, - "123-456", + "12.3-45.6", suggested_object_id="home", disabled_by=None, ) diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 68dd31af6f7010..8ef581e868169f 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -195,7 +195,14 @@ async def test_ssdp_discovery_exist(hass: HomeAssistant) -> None: async def test_user_invalid_host(hass: HomeAssistant) -> None: """Test that errors are shown when the host is invalid.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "invalid/host"} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "invalid/host"} ) assert result["errors"] == {CONF_HOST: "invalid_host"} @@ -219,7 +226,13 @@ async def test_pin_form_error(hass: HomeAssistant, side_effect, error_message) - patch("pybravia.BraviaClient.pair"), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "bravia-host"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_USE_PSK: False} @@ -246,7 +259,13 @@ async def test_psk_form_error(hass: HomeAssistant, side_effect, error_message) - side_effect=side_effect, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "bravia-host"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_USE_PSK: True} @@ -262,7 +281,13 @@ async def test_no_ip_control(hass: HomeAssistant) -> None: """Test that error are shown when IP Control is disabled on the TV.""" with patch("pybravia.BraviaClient.pair", side_effect=BraviaError): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "bravia-host"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_USE_PSK: False} @@ -296,7 +321,13 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "bravia-host"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_USE_PSK: False} @@ -332,7 +363,13 @@ async def test_create_entry(hass: HomeAssistant, use_psk, use_ssl) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "bravia-host"} ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 6d3d588027aeb7..94bead299507af 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -10,7 +10,7 @@ import aiohttp from freezegun.api import FrozenDateTimeFactory -from hass_nabucasa import AlreadyConnectedError +from hass_nabucasa import AlreadyConnectedError, AuthTimeoutError from hass_nabucasa.auth import ( InvalidTotpCode, MFARequired, @@ -388,6 +388,24 @@ async def test_login_view_request_timeout( assert req.status == HTTPStatus.BAD_GATEWAY +async def test_login_view_request_auth_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test authentication timeout while trying to log in.""" + cloud_client = await hass_client() + cloud.login.side_effect = AuthTimeoutError + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert cloud.login.call_args[1]["check_connection"] is False + + assert req.status == HTTPStatus.GATEWAY_TIMEOUT + + async def test_login_view_with_already_existing_connection( cloud: MagicMock, setup_cloud: None, diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 02040d1b49ea34..67acc84ee11271 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -737,7 +737,8 @@ async def test_remove_device_composite( await dr.async_load(hass) # pylint: disable-next=home-assistant-tests-registry-fixtures registry = dr.async_get(hass) - assert registry.async_is_composite_device_id(composite_id) is True + assert registry.async_get(composite_id) is not None + assert registry.async_get(composite_id, include_composite_devices=False) is None response = await _send_remove_device( client, command, composite_id, entry_1.entry_id diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index 86a685abaeccd4..498c4d56d60f74 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -63,6 +63,7 @@ def mock_receiver() -> Generator[MagicMock]: receiver = MagicMock(spec=Receiver) receiver.name = "Mock Lyngdorf" receiver.connected = True + receiver.model = LyngdorfModel.MP_60 # Diagnostics reports the whole receiver, so every property it reads # needs a value here; an unset one is a mock the response cannot encode. @@ -101,6 +102,15 @@ def mock_receiver() -> Generator[MagicMock]: receiver.available_video_inputs = ["hdmi"] receiver.available_stream_types = ["AirPlay", "DLNA"] + receiver.now_playing = None + receiver.has_position = False + receiver.position_ms = None + receiver.position_updated_at = None + receiver.shuffle = None + receiver.repeat = None + receiver.can_shuffle = False + receiver.available_repeat_modes = frozenset() + receiver.zone_b_power_on = False receiver.zone_b_volume = -40.0 receiver.zone_b_mute_enabled = False @@ -139,6 +149,12 @@ def notify_receiver_update(receiver: MagicMock) -> None: call.args[0]() +def notify_position_jump(receiver: MagicMock, position_ms: int | None) -> None: + """Fire every position jump callback the entities registered.""" + for call in receiver.register_position_jump_callback.call_args_list: + call.args[0](position_ms) + + @pytest.fixture def platforms() -> list[Platform]: """Platforms to load; override per module to isolate a single platform.""" diff --git a/tests/components/lyngdorf/snapshots/test_media_player.ambr b/tests/components/lyngdorf/snapshots/test_media_player.ambr index fd9a7f661b94f7..7123e2b1db0f40 100644 --- a/tests/components/lyngdorf/snapshots/test_media_player.ambr +++ b/tests/components/lyngdorf/snapshots/test_media_player.ambr @@ -105,3 +105,121 @@ 'state': 'off', }) # --- +# name: test_now_playing[media_player.mock_lyngdorf_main_zone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.mock_lyngdorf_main_zone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Main zone', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main zone', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'main_zone', + 'unique_id': '0050c27c76b2_main_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_now_playing[media_player.mock_lyngdorf_main_zone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'receiver', + : '/api/media_player_proxy/media_player.mock_lyngdorf_main_zone?token=mock_token&cache=88df577ff5b5b90f', + : 'Mock Lyngdorf Main zone', + : False, + : 'Songs to Learn & Sing', + : 'Echo & the Bunnymen', + : , + : 346, + : 319, + : datetime.datetime(2026, 8, 17, 13, 0, tzinfo=datetime.timezone.utc), + : 'The Killing Moon', + : , + : False, + : , + : 0.48345439870863605, + }), + 'context': , + 'entity_id': 'media_player.mock_lyngdorf_main_zone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_now_playing[media_player.mock_lyngdorf_zone_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.mock_lyngdorf_zone_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0050c27c76b2_zone_b', + 'unit_of_measurement': None, + }) +# --- +# name: test_now_playing[media_player.mock_lyngdorf_zone_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'receiver', + : 'Mock Lyngdorf Zone B', + : , + }), + 'context': , + 'entity_id': 'media_player.mock_lyngdorf_zone_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lyngdorf/test_config_flow.py b/tests/components/lyngdorf/test_config_flow.py index ac001aa7d6e6ca..7dabfb3a8ea463 100644 --- a/tests/components/lyngdorf/test_config_flow.py +++ b/tests/components/lyngdorf/test_config_flow.py @@ -368,3 +368,162 @@ async def test_ssdp_discovery_connectivity_check_aborts( assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected_reason + + +@pytest.mark.usefixtures("mock_find_receiver_model", "mock_get_device_serial") +async def test_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring an entry updates the host.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.50" + + +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_reconfigure_different_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_device_serial: AsyncMock, +) -> None: + """Test an entry cannot be pointed at a different device.""" + mock_config_entry.add_to_hass(hass) + mock_get_device_serial.return_value = "aabbccddeeff" + original_host = mock_config_entry.data[CONF_HOST] + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data[CONF_HOST] == original_host + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TimeoutError, "timeout_connect"), + (OSError, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_get_device_serial") +async def test_reconfigure_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_find_receiver_model: AsyncMock, + side_effect: type[Exception], + error: str, +) -> None: + """Test reconfigure surfaces connection errors and recovers.""" + mock_config_entry.add_to_hass(hass) + mock_find_receiver_model.side_effect = side_effect + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_find_receiver_model.side_effect = None + mock_find_receiver_model.return_value = LyngdorfModel.MP_60 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.50"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TimeoutError, "timeout_connect"), + (OSError, "cannot_connect"), + ], +) +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_user_flow_serial_errors( + hass: HomeAssistant, + mock_get_device_serial: AsyncMock, + side_effect: type[Exception], + error: str, +) -> None: + """Test a failure to read the serial is surfaced on the form.""" + mock_get_device_serial.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_get_device_serial.side_effect = None + mock_get_device_serial.return_value = "0050c27c76b2" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("model", "serial", "error"), + [ + pytest.param(None, "0050c27c76b2", "unsupported_model", id="unsupported"), + pytest.param(LyngdorfModel.MP_60, None, "cannot_determine_id", id="no_serial"), + ], +) +async def test_reconfigure_device_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_find_receiver_model: AsyncMock, + mock_get_device_serial: AsyncMock, + model: LyngdorfModel | None, + serial: str | None, + error: str, +) -> None: + """Test reconfigure surfaces a device it cannot identify.""" + mock_config_entry.add_to_hass(hass) + mock_find_receiver_model.return_value = model + mock_get_device_serial.return_value = serial + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_find_receiver_model.return_value = LyngdorfModel.MP_60 + mock_get_device_serial.return_value = "0050c27c76b2" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.50"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/lyngdorf/test_media_player.py b/tests/components/lyngdorf/test_media_player.py index 89edb45dc92cbe..db6f39e8cf0ae2 100644 --- a/tests/components/lyngdorf/test_media_player.py +++ b/tests/components/lyngdorf/test_media_player.py @@ -1,25 +1,45 @@ """Tests for the Lyngdorf media player platform.""" +from collections.abc import Generator +from datetime import UTC, datetime +from typing import Any from unittest.mock import MagicMock, patch from lyngdorf.const import LyngdorfModel +from lyngdorf.states import Control, PlaybackState, Repeat +from lyngdorf.streaming import NowPlaying import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEEK_POSITION, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE, ATTR_SOUND_MODE_LIST, DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, + MediaPlayerEntityFeature, MediaPlayerState, + RepeatMode, ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_REPEAT_SET, + SERVICE_SHUFFLE_SET, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, @@ -32,8 +52,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import notify_position_jump, notify_receiver_update + from tests.common import MockConfigEntry, snapshot_platform +POSITION_UPDATED_AT = datetime(2026, 8, 17, 13, tzinfo=UTC) + MAIN_ZONE = "media_player.mock_lyngdorf_main_zone" ZONE_B = "media_player.mock_lyngdorf_zone_b" @@ -44,6 +68,45 @@ def platforms() -> list[Platform]: return [Platform.MEDIA_PLAYER] +@pytest.fixture(autouse=True) +def media_proxy_token() -> Generator[None]: + """Freeze the media proxy token, which otherwise varies per run.""" + with patch("secrets.token_hex", return_value="mock_token"): + yield + + +@pytest.fixture +def playing_receiver(mock_receiver: MagicMock) -> MagicMock: + """Return a receiver that is streaming a track.""" + mock_receiver.power_on = True + mock_receiver.now_playing = NowPlaying( + state=PlaybackState.PLAYING, + title="The Killing Moon", + artist="Echo & the Bunnymen", + album="Songs to Learn & Sing", + source="Total Solar Eclipse Playlist", + art_url="https://example.test/art.jpg", + duration_ms=346280, + controls=frozenset( + { + Control.PAUSE, + Control.NEXT_TRACK, + Control.PREVIOUS_TRACK, + Control.SEEK, + } + ), + play_modes=frozenset(), + ) + mock_receiver.has_position = True + mock_receiver.position_ms = 318544 + mock_receiver.position_updated_at = POSITION_UPDATED_AT + mock_receiver.shuffle = False + mock_receiver.repeat = Repeat.OFF + mock_receiver.can_shuffle = True + mock_receiver.available_repeat_modes = frozenset({Repeat.OFF, Repeat.ALL}) + return mock_receiver + + async def test_entities( hass: HomeAssistant, init_integration: MockConfigEntry, @@ -226,23 +289,15 @@ async def test_availability( mock_receiver: MagicMock, ) -> None: """Test availability when device disconnects and reconnects.""" - callbacks = [ - call.args[0] - for call in mock_receiver.register_notification_callback.call_args_list - ] - assert callbacks - mock_receiver.connected = False - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() assert hass.states.get(MAIN_ZONE).state == STATE_UNAVAILABLE assert hass.states.get(ZONE_B).state == STATE_UNAVAILABLE mock_receiver.connected = True - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() assert hass.states.get(MAIN_ZONE).state != STATE_UNAVAILABLE @@ -255,11 +310,6 @@ async def test_main_zone_state_properties( mock_receiver: MagicMock, ) -> None: """Test main zone state properties are reported correctly.""" - callbacks = [ - call.args[0] - for call in mock_receiver.register_notification_callback.call_args_list - ] - mock_receiver.power_on = True mock_receiver.volume = -40.0 mock_receiver.mute_enabled = False @@ -267,8 +317,7 @@ async def test_main_zone_state_properties( mock_receiver.sound_mode = "Movie" mock_receiver.available_sources = ["HDMI", "Optical"] mock_receiver.available_sound_modes = ["Movie", "Stereo"] - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() state = hass.states.get(MAIN_ZONE) @@ -281,15 +330,13 @@ async def test_main_zone_state_properties( assert state.attributes[ATTR_SOUND_MODE_LIST] == ["Movie", "Stereo"] mock_receiver.volume = None - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() state = hass.states.get(MAIN_ZONE) assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None mock_receiver.power_on = False - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() state = hass.states.get(MAIN_ZONE) assert state.state == MediaPlayerState.OFF @@ -301,18 +348,12 @@ async def test_zone_b_state_properties( mock_receiver: MagicMock, ) -> None: """Test zone B state properties are reported correctly.""" - callbacks = [ - call.args[0] - for call in mock_receiver.register_notification_callback.call_args_list - ] - mock_receiver.zone_b_power_on = True mock_receiver.zone_b_volume = -30.0 mock_receiver.zone_b_mute_enabled = True mock_receiver.zone_b_source = "Optical" mock_receiver.zone_b_available_sources = ["HDMI", "Optical"] - for cb in callbacks: - cb() + notify_receiver_update(mock_receiver) await hass.async_block_till_done() state = hass.states.get(ZONE_B) @@ -321,3 +362,139 @@ async def test_zone_b_state_properties( assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is True assert state.attributes[ATTR_INPUT_SOURCE] == "Optical" assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HDMI", "Optical"] + + +async def test_now_playing( + hass: HomeAssistant, + init_integration: MockConfigEntry, + playing_receiver: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test now-playing metadata, position and transport features while playing.""" + notify_receiver_update(playing_receiver) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.usefixtures("mock_receiver") +async def test_transport_features_absent_when_idle( + hass: HomeAssistant, +) -> None: + """Test no transport is offered when nothing is playing.""" + features = hass.states.get(MAIN_ZONE).attributes[ATTR_SUPPORTED_FEATURES] + assert not features & MediaPlayerEntityFeature.PAUSE + assert not features & MediaPlayerEntityFeature.SEEK + + +@pytest.mark.parametrize( + ("service", "method"), + [ + pytest.param(SERVICE_MEDIA_PAUSE, "async_pause", id="pause"), + pytest.param(SERVICE_MEDIA_NEXT_TRACK, "async_next", id="next"), + pytest.param(SERVICE_MEDIA_PREVIOUS_TRACK, "async_previous", id="previous"), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_transport_actions( + hass: HomeAssistant, + playing_receiver: MagicMock, + service: str, + method: str, +) -> None: + """Test transport actions reach the receiver.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MAIN_ZONE}, + blocking=True, + ) + getattr(playing_receiver, method).assert_awaited_once() + + +@pytest.mark.usefixtures("init_integration") +async def test_seek_converts_to_milliseconds( + hass: HomeAssistant, + playing_receiver: MagicMock, +) -> None: + """Test seek converts the position Home Assistant gives in seconds.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_SEEK, + {ATTR_ENTITY_ID: MAIN_ZONE, ATTR_MEDIA_SEEK_POSITION: 42.5}, + blocking=True, + ) + playing_receiver.async_seek.assert_awaited_once_with(42500) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("service", "payload", "method", "expected"), + [ + pytest.param( + SERVICE_SHUFFLE_SET, + {ATTR_MEDIA_SHUFFLE: True}, + "async_set_shuffle", + True, + id="shuffle", + ), + pytest.param( + SERVICE_REPEAT_SET, + {ATTR_MEDIA_REPEAT: RepeatMode.ALL}, + "async_set_repeat", + Repeat.ALL, + id="repeat", + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_play_mode( + hass: HomeAssistant, + playing_receiver: MagicMock, + service: str, + payload: dict[str, Any], + method: str, + expected: bool | Repeat, +) -> None: + """Test shuffle and repeat are set on their own axes.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MAIN_ZONE} | payload, + blocking=True, + ) + getattr(playing_receiver, method).assert_awaited_once_with(expected) + + +@pytest.mark.usefixtures("init_integration") +async def test_no_streaming_features_on_model_without_streamer( + hass: HomeAssistant, + playing_receiver: MagicMock, +) -> None: + """Test a model with no streaming module offers no transport.""" + playing_receiver.model = LyngdorfModel.TDAI_2170 + notify_receiver_update(playing_receiver) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ZONE) + assert ( + not state.attributes[ATTR_SUPPORTED_FEATURES] & MediaPlayerEntityFeature.PAUSE + ) + assert state.attributes.get(ATTR_MEDIA_TITLE) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_position_jump_updates_state( + hass: HomeAssistant, + playing_receiver: MagicMock, +) -> None: + """Test a position discontinuity refreshes the reported position.""" + playing_receiver.position_ms = 1000 + notify_position_jump(playing_receiver, 1000) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ZONE) + assert state.attributes[ATTR_MEDIA_POSITION] == 1 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == POSITION_UPDATED_AT diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 3cd5c2b575371f..3a648f22637da4 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.api import MassEvent -from music_assistant_models.auth import User from music_assistant_models.enums import EventType from music_assistant_models.media_items import ( Album, @@ -75,9 +74,6 @@ async def setup_integration_from_fixtures( music.get_library_podcasts = AsyncMock(return_value=library_podcasts) music.get_item_by_uri = AsyncMock() - users = create_users_from_fixture() - music_assistant_client.auth.list_users = AsyncMock(return_value=users) - config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -158,12 +154,6 @@ def create_library_podcasts_from_fixture() -> list[Podcast]: return [Podcast.from_dict(radio_data) for radio_data in fixture_data] -def create_users_from_fixture() -> list[User]: - """Create MA Users from fixture.""" - fixture_data = load_and_parse_fixture("users") - return [User.from_dict(user_data) for user_data in fixture_data] - - async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, diff --git a/tests/components/music_assistant/fixtures/users.json b/tests/components/music_assistant/fixtures/users.json deleted file mode 100644 index 59e2fab175cb4b..00000000000000 --- a/tests/components/music_assistant/fixtures/users.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "users": [ - { - "user_id": "1jKp69KqXH3HlkOLUNgnrvYL_NjwxqtsPhCuVe6Mnpc", - "username": "party_guest", - "role": "guest", - "enabled": true, - "created_at": "2026-03-25T19:42:38.316796+00:00", - "display_name": "Party Guest", - "avatar_url": null, - "preferences": {}, - "provider_filter": [], - "player_filter": [] - }, - { - "user_id": "W4SJjiCfzHeAHPjzy1IsXkdnIJHkzhLXu9Vp-V9u730", - "username": "user_admin", - "role": "admin", - "enabled": true, - "created_at": "2026-05-04T19:29:13.853819+00:00", - "display_name": "Admin", - "avatar_url": null, - "preferences": {}, - "provider_filter": [], - "player_filter": [] - }, - { - "user_id": "aTn3wTtZi-Lznf_WxQQn4NKElrfpa00LV8ZDzIIF_uU", - "username": "user_user", - "role": "user", - "enabled": true, - "created_at": "2026-05-04T19:29:13.853819+00:00", - "display_name": "User", - "avatar_url": null, - "preferences": {}, - "provider_filter": [], - "player_filter": [] - }, - { - "user_id": "bZc3wTtZi-Lznf_WxQRn4NRElrfra00LV9ZDzIIF_uU", - "username": "user_disabled", - "role": "user", - "enabled": false, - "created_at": "2026-05-03T19:29:13.853819+00:00", - "display_name": "Disabled user", - "avatar_url": null, - "preferences": {}, - "provider_filter": [], - "player_filter": [] - } - ] -} diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 00225be7b2ff08..5de1b9dad5cc59 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -9,13 +9,13 @@ PlayerFeature, QueueOption, ) +from music_assistant_models.errors import UserNotFoundError from music_assistant_models.media_items import Track from music_assistant_models.player import PlayerMedia import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths -from homeassistant.auth.models import Credentials from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, @@ -523,7 +523,7 @@ async def test_media_player_play_media_action_legacy( artist="artist", album="album", media_type=None, - username=None, + user=None, ) assert music_assistant_client.send_command.call_count == 1 assert music_assistant_client.send_command.call_args == call( @@ -562,48 +562,6 @@ async def test_media_player_play_media_action_legacy( sort_by=None, username="user_user", ) - # invalid username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "non_existing_username", - }, - blocking=True, - ) - # disabled username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "user_disabled", - }, - blocking=True, - ) - # guest username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "party_guest", - }, - blocking=True, - ) async def test_media_player_play_media_action( @@ -744,7 +702,7 @@ async def test_media_player_play_media_action( artist="artist", album="album", media_type=None, - username=None, + user=None, ) assert music_assistant_client.send_command.call_count == 1 assert music_assistant_client.send_command.call_args == call( @@ -784,101 +742,61 @@ async def test_media_player_play_media_action( sort_by=None, username="user_user", ) - # invalid username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "non_existing_username", - }, - blocking=True, - ) - # disabled username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "user_disabled", - }, - blocking=True, - ) - # guest username - music_assistant_client.send_command.reset_mock() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_MEDIA_ADVANCED, - { - ATTR_ENTITY_ID: entity_id, - ATTR_MEDIA_ID: "spotify://track/1234", - ATTR_MEDIA_ENQUEUE: "add", - ATTR_USERNAME: "party_guest", - }, - blocking=True, - ) -async def _add_ha_user(hass: HomeAssistant, login_username: str | None) -> MockUser: - """Add a Home Assistant user, optionally with a local login username.""" +async def test_media_player_play_media_default_user( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that play media defaults to the calling Home Assistant user. + + The calling user is forwarded as a soft (required=False) provider-link user + reference; the server resolves it to a Music Assistant user by provider link + (or plays as the default account). + """ + music_assistant_client.server_info.schema_version = 44 + music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + user = MockUser(is_owner=True).add_to_hass(hass) - if login_username is not None: - await hass.auth.async_link_user( - user, - Credentials( - auth_provider_type="homeassistant", - auth_provider_id=None, - data={"username": login_username}, - is_new=False, - ), - ) - return user - - -@pytest.mark.parametrize( - ("login_username", "expected_username"), - [ - ("user_user", "user_user"), - ("USER_USER", "user_user"), - ("user_admin", "user_admin"), - ("does_not_exist", None), - ("user_disabled", None), - ("party_guest", None), - (None, None), - ], - ids=[ - "match", - "match_case_insensitive", - "match_admin", - "no_match", - "disabled_user", - "guest_user", - "no_login_username", - ], -) -async def test_media_player_play_media_default_username( + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + }, + blocking=True, + context=Context(user_id=user.id), + ) + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + sort_by=None, + user={"provider": "homeassistant", "user_id": user.id, "required": False}, + ) + + +async def test_media_player_play_media_default_user_older_server( hass: HomeAssistant, music_assistant_client: MagicMock, - login_username: str | None, - expected_username: str | None, ) -> None: - """Test that play media defaults to the username of the calling user.""" - music_assistant_client.server_info.schema_version = 33 + """Test that older servers (no provider-link support) simply do not impersonate.""" + # the provider-link user reference is only sent to schema >= 44 servers; being + # soft (required=False), it gracefully degrades to no impersonation at all + music_assistant_client.server_info.schema_version = 35 music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) await setup_integration_from_fixtures(hass, music_assistant_client) entity_id = "media_player.test_player_1" mass_player_id = "00:00:00:00:00:01" - user = await _add_ha_user(hass, login_username) + user = MockUser(is_owner=True).add_to_hass(hass) await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA_ADVANCED, @@ -896,23 +814,22 @@ async def test_media_player_play_media_default_username( option=None, radio_mode=False, start_item=None, - username=expected_username, sort_by=None, ) -async def test_media_player_play_media_default_username_explicit_override( +async def test_media_player_play_media_explicit_user_override( hass: HomeAssistant, music_assistant_client: MagicMock, ) -> None: """Test that an explicit username takes precedence over the calling user.""" - music_assistant_client.server_info.schema_version = 33 + music_assistant_client.server_info.schema_version = 44 music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) await setup_integration_from_fixtures(hass, music_assistant_client) entity_id = "media_player.test_player_1" mass_player_id = "00:00:00:00:00:01" - user = await _add_ha_user(hass, "user_user") + user = MockUser(is_owner=True).add_to_hass(hass) await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA_ADVANCED, @@ -931,23 +848,79 @@ async def test_media_player_play_media_default_username_explicit_override( option=None, radio_mode=False, start_item=None, - username="user_admin", sort_by=None, + user="user_admin", + ) + + +async def test_media_player_play_media_unknown_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that a username the server does not know raises a translated error.""" + music_assistant_client.server_info.schema_version = 44 + music_assistant_client.music.verify_item_uri = AsyncMock( + side_effect=UserNotFoundError( + "A user with user id or name nobody is not available." + ) ) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + ATTR_USERNAME: "nobody", + }, + blocking=True, + ) + assert err.value.translation_key == "invalid_username" + assert err.value.translation_placeholders == {"username": "nobody"} -async def test_media_player_standard_play_media_default_username( +async def test_media_player_play_media_user_not_found_without_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that a UserNotFoundError without an explicit username is not mislabeled.""" + music_assistant_client.server_info.schema_version = 44 + music_assistant_client.music.verify_item_uri = AsyncMock( + side_effect=UserNotFoundError( + "A user with user id or name nobody is not available." + ) + ) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + }, + blocking=True, + ) + assert not isinstance(err.value, ServiceValidationError) + + +async def test_media_player_standard_play_media_default_user( hass: HomeAssistant, music_assistant_client: MagicMock, ) -> None: """Test that the standard play_media action also defaults to the calling user.""" - music_assistant_client.server_info.schema_version = 33 + music_assistant_client.server_info.schema_version = 44 music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) await setup_integration_from_fixtures(hass, music_assistant_client) entity_id = "media_player.test_player_1" mass_player_id = "00:00:00:00:00:01" - user = await _add_ha_user(hass, "user_user") + user = MockUser(is_owner=True).add_to_hass(hass) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -966,8 +939,8 @@ async def test_media_player_standard_play_media_default_username( option=None, radio_mode=False, start_item=None, - username="user_user", sort_by=None, + user={"provider": "homeassistant", "user_id": user.id, "required": False}, ) @@ -996,11 +969,14 @@ async def test_media_player_play_announcement_action( assert music_assistant_client.send_command.call_count == 1 assert music_assistant_client.send_command.call_args == call( "players/cmd/play_announcement", + require_schema=None, player_id=mass_player_id, url="http://blah.com/announcement.mp3", pre_announce=True, volume_level=50, pre_announce_url="http://blah.com/chime.mp3", + message=None, + tts_engine=None, ) diff --git a/tests/components/music_assistant/test_services.py b/tests/components/music_assistant/test_services.py index bed611b830fe4c..b17902fcb389f5 100644 --- a/tests/components/music_assistant/test_services.py +++ b/tests/components/music_assistant/test_services.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, call from music_assistant_models.enums import MediaType +from music_assistant_models.errors import UserNotFoundError from music_assistant_models.media_items import SearchResults import pytest from syrupy.assertion import SnapshotAssertion @@ -87,21 +88,33 @@ async def test_search_action_with_username( require_schema=35, ) - # not valid because of name, disabled or guest - for username in ("non_existing_user", "party_guest", "user_disabled"): - with pytest.raises(ServiceValidationError) as exc: - await hass.services.async_call( - DOMAIN, - SERVICE_SEARCH, - { - ATTR_CONFIG_ENTRY_ID: entry.entry_id, - ATTR_SEARCH_NAME: "test", - ATTR_USERNAME: username, - }, - blocking=True, - return_response=True, - ) - assert exc.value.translation_key == "invalid_username" + +async def test_search_action_with_unknown_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that a username the server does not know raises a translated error.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + music_assistant_client.music.search = AsyncMock( + side_effect=UserNotFoundError( + "A user with user id or name nobody is not available." + ) + ) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_SEARCH_NAME: "test", + ATTR_USERNAME: "nobody", + }, + blocking=True, + return_response=True, + ) + assert err.value.translation_key == "invalid_username" + assert err.value.translation_placeholders == {"username": "nobody"} @pytest.mark.parametrize( @@ -160,22 +173,7 @@ async def test_get_library_action_with_username( # username supported from schema 35 and above music_assistant_client.server_info.schema_version = 35 - # invalid users - for username in ("non_existing_user", "party_guest", "user_disabled"): - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_GET_LIBRARY, - { - ATTR_CONFIG_ENTRY_ID: entry.entry_id, - ATTR_FAVORITE: False, - ATTR_MEDIA_TYPE: media_type, - ATTR_USERNAME: username, - }, - blocking=True, - return_response=True, - ) - # valid user + # an explicit username is forwarded to the server (which validates it) await hass.services.async_call( DOMAIN, SERVICE_GET_LIBRARY, @@ -188,3 +186,31 @@ async def test_get_library_action_with_username( blocking=True, return_response=True, ) + + +async def test_get_library_action_with_unknown_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that a username the server does not know raises a translated error.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + music_assistant_client.music.get_library_tracks = AsyncMock( + side_effect=UserNotFoundError( + "A user with user id or name nobody is not available." + ) + ) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_LIBRARY, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_MEDIA_TYPE: "track", + ATTR_USERNAME: "nobody", + }, + blocking=True, + return_response=True, + ) + assert err.value.translation_key == "invalid_username" + assert err.value.translation_placeholders == {"username": "nobody"} diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index fd886b99ae8450..8c63967e32171b 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -749,6 +749,210 @@ def _make_cursor_mock(*_): assert database_engine.optimizer.slow_dependent_subquery is False +async def test_issue_for_deprecated_pgsql_version( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we create and delete an issue for a PostgreSQL version below the minimum.""" + instance_mock = MagicMock() + instance_mock.hass = hass + execute_args = [] + close_mock = MagicMock() + reported_version = "13.2" + + def execute_mock(statement): + nonlocal execute_args + execute_args.append(statement) + + def fetchall_mock(): + nonlocal execute_args + if execute_args[-1] == "SHOW server_version": + return [[reported_version]] + return None + + def _make_cursor_mock(*_): + return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) + + dbapi_connection = MagicMock(cursor=_make_cursor_mock) + + database_engine = await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "postgresql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "database_engine_too_old") + assert issue is not None + assert issue.breaks_in_ha_version == "2027.3.0" + assert issue.translation_placeholders == { + "database_engine": "PostgreSQL", + "server_version": "13.2", + "min_version": "15.0", + } + assert database_engine is not None + + reported_version = "15.2" + database_engine = await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "postgresql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, "database_engine_too_old") is None + assert database_engine is not None + + +@pytest.mark.parametrize( + ( + "server_version", + "extracted_version", + "engine_name", + "lts_versions", + "supported_version", + ), + [ + ( + "10.6.0-MariaDB", + "10.6.0", + "MariaDB", + "10.11, 11.4, 11.8, 12.3", + "11.8.1-MariaDB", + ), + ( + "11.5.0-MariaDB", + "11.5.0", + "MariaDB", + "10.11, 11.4, 11.8, 12.3", + "11.8.1-MariaDB", + ), + ("8.0.0", "8.0.0", "MySQL", "8.4, 9.7", "8.4.0"), + ("8.2.0", "8.2.0", "MySQL", "8.4, 9.7", "9.7.0"), + ], +) +async def test_issue_for_not_supported_lts_version( + hass: HomeAssistant, + server_version: str, + extracted_version: str, + engine_name: str, + lts_versions: str, + supported_version: str, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we warn about MariaDB/MySQL versions that are not a supported LTS release.""" + instance_mock = MagicMock() + instance_mock.hass = hass + execute_args = [] + close_mock = MagicMock() + reported_version = server_version + + def execute_mock(statement): + nonlocal execute_args + execute_args.append(statement) + + def fetchall_mock(): + nonlocal execute_args + if execute_args[-1] == "SELECT VERSION()": + return [[reported_version]] + return None + + def _make_cursor_mock(*_): + return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) + + dbapi_connection = MagicMock(cursor=_make_cursor_mock) + + database_engine = await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "mysql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "database_engine_not_supported_lts") + assert issue is not None + assert issue.breaks_in_ha_version == "2027.3.0" + assert issue.translation_placeholders == { + "database_engine": engine_name, + "server_version": extracted_version, + "lts_versions": lts_versions, + } + assert database_engine is not None + + reported_version = supported_version + database_engine = await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "mysql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue(DOMAIN, "database_engine_not_supported_lts") + is None + ) + assert database_engine is not None + + +@pytest.mark.parametrize( + "server_version", + [ + "12.4.0-MariaDB", # non-LTS MariaDB release newer than we know about + "13.4.0-MariaDB", # LTS MariaDB release newer than we know about + "9.8.0", # non-LTS MySQL release newer than we know about + "10.4.0", # LTS MySQL release newer than we know about + ], +) +async def test_no_issue_for_future_database_version( + hass: HomeAssistant, + server_version: str, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we assume versions newer than the latest known non-LTS release are supported.""" + instance_mock = MagicMock() + instance_mock.hass = hass + execute_args = [] + close_mock = MagicMock() + + def execute_mock(statement): + nonlocal execute_args + execute_args.append(statement) + + def fetchall_mock(): + nonlocal execute_args + if execute_args[-1] == "SELECT VERSION()": + return [[server_version]] + return None + + def _make_cursor_mock(*_): + return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) + + dbapi_connection = MagicMock(cursor=_make_cursor_mock) + + database_engine = await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "mysql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue(DOMAIN, "database_engine_not_supported_lts") + is None + ) + assert database_engine is not None + + @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) @pytest.mark.usefixtures("skip_by_db_engine") async def test_basic_sanity_check( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index a02570ff0dbc7e..ef0f89545d8c9a 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -144,6 +144,20 @@ def disable_mariadb_issue() -> None: yield +@pytest.fixture(autouse=True) +def disable_deprecated_database_version_issue() -> None: + """Disable creating issues about deprecated database versions.""" + with ( + patch( + "homeassistant.components.recorder.util._async_create_issue_deprecated_version" + ), + patch( + "homeassistant.components.recorder.util._async_create_issue_not_supported_lts" + ), + ): + yield + + async def async_list_statistic_ids( hass: HomeAssistant, statistic_ids: set[str] | None = None, diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 62941e0ffbe9fb..d032eec14fdda8 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -466,6 +466,25 @@ def mock_white_light_set_state( "wifi": {"rssi": -63}, } +MOCK_CAMERA_CONFIG = { + "camera:0": { + "id": 0, + "rtsp": {"enable": True}, + } +} + +MOCK_CAMERA_STATUS = { + "camera:0": { + "id": 0, + "privacy": False, + "arm": True, + "streamer": "running", + "motion": False, + "streams": 0, + "recordings": None, + } +} + MOCK_SCRIPTS = [ """" function eventHandler(event, userdata) { @@ -821,3 +840,14 @@ def disable_async_remove_shelly_rpc_entities() -> Generator[None]: "homeassistant.components.shelly.utils.async_remove_shelly_rpc_entities" ): yield + + +@pytest.fixture +def mock_camera_rpc_device( + monkeypatch: pytest.MonkeyPatch, mock_rpc_device: Mock +) -> Mock: + """Set up mock RPC device with camera component data.""" + monkeypatch.setattr(mock_rpc_device, "config", MOCK_CAMERA_CONFIG) + monkeypatch.setattr(mock_rpc_device, "status", MOCK_CAMERA_STATUS) + + return mock_rpc_device diff --git a/tests/components/shelly/snapshots/test_camera.ambr b/tests/components/shelly/snapshots/test_camera.ambr new file mode 100644 index 00000000000000..d7ae1a3ffdabe6 --- /dev/null +++ b/tests/components/shelly/snapshots/test_camera.ambr @@ -0,0 +1,111 @@ +# serializer version: 1 +# name: test_camera_entity_setup[camera.test_name_stream_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_name_stream_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stream 0', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stream 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'stream', + 'unique_id': '123456789ABC-camera:0-stream_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_camera_entity_setup[camera.test_name_stream_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : 'Shelly', + : '/api/camera_proxy/camera.test_name_stream_0?token=1caab5c3b3', + : 'Test name Stream 0', + : 'S1CM-0DXW00', + : , + }), + 'context': , + 'entity_id': 'camera.test_name_stream_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera_entity_setup[camera.test_name_stream_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_name_stream_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stream 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stream 1', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'stream', + 'unique_id': '123456789ABC-camera:0-stream_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_camera_entity_setup[camera.test_name_stream_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : 'Shelly', + : '/api/camera_proxy/camera.test_name_stream_1?token=1caab5c3b3', + : 'Test name Stream 1', + : 'S1CM-0DXW00', + : , + }), + 'context': , + 'entity_id': 'camera.test_name_stream_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/shelly/test_camera.py b/tests/components/shelly/test_camera.py new file mode 100644 index 00000000000000..89366affd43bd1 --- /dev/null +++ b/tests/components/shelly/test_camera.py @@ -0,0 +1,193 @@ +"""Tests for Shelly camera platform.""" + +from collections.abc import Generator +from copy import deepcopy +from unittest.mock import Mock, patch + +from aioshelly.const import MODEL_CAMERA +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.camera import ( + DATA_COMPONENT, + CameraState, + get_camera_from_entity_id, +) +from homeassistant.components.shelly.const import CONF_SLEEP_PERIOD +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import MOCK_MAC, init_integration, patch_platforms + +from tests.common import snapshot_platform + +CAMERA_ENTITY_ID = "camera.test_name_stream_0" + + +@pytest.fixture(autouse=True) +def fixture_platforms() -> Generator[None]: + """Limit platforms under test.""" + with patch_platforms([Platform.CAMERA]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_camera_entity_setup( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test camera entity is created with correct unique_id and initial state.""" + with patch("random.SystemRandom.getrandbits", return_value=123123123123): + entry = await init_integration(hass, 3, model=MODEL_CAMERA) + + assert hass.states.get(CAMERA_ENTITY_ID) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + assert (er_entry := entity_registry.async_get(CAMERA_ENTITY_ID)) + assert er_entry.unique_id == f"{MOCK_MAC}-camera:0-stream_0" + + +async def test_camera_state_streaming( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test camera state is streaming when streams > 0.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + new_status = deepcopy(mock_camera_rpc_device.status) + new_status["camera:0"]["streams"] = 1 + monkeypatch.setattr(mock_camera_rpc_device, "status", new_status) + mock_camera_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(CAMERA_ENTITY_ID)) + assert state.state == CameraState.STREAMING + + +async def test_camera_state_recording( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test camera state is recording when recordings is set.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + new_status = deepcopy(mock_camera_rpc_device.status) + new_status["camera:0"]["recordings"] = {"id": 1} + monkeypatch.setattr(mock_camera_rpc_device, "status", new_status) + mock_camera_rpc_device.mock_update() + await hass.async_block_till_done() + + assert (state := hass.states.get(CAMERA_ENTITY_ID)) + assert state.state == CameraState.RECORDING + + +async def test_camera_use_stream_for_stills( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, +) -> None: + """Test use_stream_for_stills returns True (still images from the RTSP stream).""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + assert camera.use_stream_for_stills is True + + +async def test_camera_stream_source( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, +) -> None: + """Test stream_source returns the RTSP URL for go2rtc.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + result = await camera.stream_source() + assert result == "rtsp://192.168.1.37/stream/0" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_camera_stream_source_stream_1( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, +) -> None: + """Test stream_source returns correct RTSP URL for stream 1.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, "camera.test_name_stream_1") + result = await camera.stream_source() + assert result == "rtsp://192.168.1.37/stream/1" + + +@pytest.mark.parametrize( + ("password", "expected_password"), + [ + ("password", "password"), + ("pass:word@1", "pass%3Aword%401"), + ], +) +async def test_camera_stream_source_with_credentials( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + password: str, + expected_password: str, +) -> None: + """Test stream_source returns the RTSP URL with credentials for go2rtc.""" + await init_integration( + hass, + 3, + model=MODEL_CAMERA, + data={ + CONF_HOST: "192.168.1.37", + CONF_MODEL: MODEL_CAMERA, + CONF_PASSWORD: password, + CONF_SLEEP_PERIOD: 0, + CONF_USERNAME: "admin", + }, + ) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + result = await camera.stream_source() + assert result == f"rtsp://admin:{expected_password}@192.168.1.37/stream/0" + + +async def test_camera_off_when_streamer_stopped( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test camera is off when the streamer is not running.""" + status = deepcopy(mock_camera_rpc_device.status) + status["camera:0"]["streamer"] = "stopped" + monkeypatch.setattr(mock_camera_rpc_device, "status", status) + + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = hass.data[DATA_COMPONENT].get_entity(CAMERA_ENTITY_ID) + assert camera is not None + assert camera.is_on is False + + +async def test_camera_properties_when_device_not_initialized( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test camera properties return safe values when the device is not initialized.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + + monkeypatch.setattr(mock_camera_rpc_device, "initialized", False) + + assert camera.is_on is False + assert camera.available is False diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 7a2aa7c02aad79..f8587a821cba4f 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock -from aioshelly.const import MODEL_1PM, MODEL_MOTION, MODEL_WALL_DISPLAY +from aioshelly.const import MODEL_1PM, MODEL_CAMERA, MODEL_MOTION, MODEL_WALL_DISPLAY from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from freezegun.api import FrozenDateTimeFactory import pytest @@ -1112,3 +1112,36 @@ async def test_rpc_circuit_breaker_turn_on_errors( {ATTR_ENTITY_ID: "switch.test_name"}, blocking=True, ) + + +async def test_rpc_camera_privacy_switch( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the camera privacy switch.""" + entity_id = "switch.test_name_privacy" + + await init_integration(hass, 3, model=MODEL_CAMERA) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-camera:0-camera_privacy" + + mutate_rpc_device_status( + monkeypatch, mock_camera_rpc_device, "camera:0", "privacy", True + ) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_camera_rpc_device.mock_update() + mock_camera_rpc_device.set_camera_privacy.assert_called_with(0, True) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 1b1c320b6c2a7f..99d114455ece56 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1695,6 +1695,33 @@ async def test_esphome_discovery_migration( assert entry.data["use_addon"] is True +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_no_home_id_configured_socket_no_migration( + hass: HomeAssistant, +) -> None: + """Test a no-home-ID reconnect of the configured socket isn't a migration.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOCKET_PATH: "esphome://192.168.1.100:6053", + "use_addon": True, + "integration_created_addon": True, + }, + title=TITLE, + unique_id="1234", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("supervisor", "addon_running", "addon_info") async def test_esphome_discovery_same_socket_no_reload( hass: HomeAssistant, diff --git a/tests/components/zwave_js/test_logbook.py b/tests/components/zwave_js/test_logbook.py index e56be4e8ea0b05..0606529f0bcc52 100644 --- a/tests/components/zwave_js/test_logbook.py +++ b/tests/components/zwave_js/test_logbook.py @@ -1,8 +1,10 @@ """The tests for Z-Wave JS logbook.""" +import pytest from zwave_js_server.const import CommandClass from homeassistant.components.zwave_js.const import ( + DOMAIN, ZWAVE_JS_NOTIFICATION_EVENT, ZWAVE_JS_VALUE_NOTIFICATION_EVENT, ) @@ -11,6 +13,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry from tests.components.logbook.common import MockRow, mock_humanify @@ -154,3 +157,84 @@ async def test_humanifying_zwave_js_value_notification_event( events[0]["message"] == "fired Scene Activation CC 'value notification' event for 'Scene ID': '001'" ) + + +@pytest.fixture(name="nameless_device_id") +def nameless_device_id_fixture( + request: pytest.FixtureRequest, + device_registry: dr.DeviceRegistry, + integration: MockConfigEntry, +) -> str: + """Return the id of a device that humanify resolves to an empty name.""" + if not request.param: + # A device id that is not in the registry, e.g. a removed device + return "removed_device_id" + # A registered device with neither a user set name nor a device name. A new + # device defaults its name to the config entry title, so clear it afterwards. + device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, "nameless-node")}, + ) + device = device_registry.async_update_device(device.id, name=None) + assert device is not None + assert device.name_by_user is None + assert device.name is None + return device.id + + +@pytest.mark.parametrize( + "nameless_device_id", + [ + pytest.param(False, id="removed_device"), + pytest.param(True, id="unnamed_device"), + ], + indirect=True, +) +async def test_humanifying_zwave_js_events_no_device_name( + hass: HomeAssistant, + nameless_device_id: str, +) -> None: + """Test humanifying Z-Wave JS events when the device name is unavailable.""" + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + await hass.async_block_till_done() + + events = mock_humanify( + hass, + [ + MockRow( + ZWAVE_JS_NOTIFICATION_EVENT, + { + "device_id": nameless_device_id, + "command_class": CommandClass.NOTIFICATION.value, + "command_class_name": "Notification", + "label": "label", + "event_label": "event_label", + }, + ), + MockRow( + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, + { + "device_id": nameless_device_id, + "command_class": CommandClass.SCENE_ACTIVATION.value, + "command_class_name": "Scene Activation", + "label": "Scene ID", + "value": "001", + }, + ), + ], + ) + + assert events[0]["name"] == "" + assert events[0]["domain"] == "zwave_js" + assert ( + events[0]["message"] + == "fired Notification CC 'notification' event 'label': 'event_label'" + ) + + assert events[1]["name"] == "" + assert events[1]["domain"] == "zwave_js" + assert ( + events[1]["message"] + == "fired Scene Activation CC 'value notification' event for 'Scene ID': '001'" + ) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 068d98264f9588..9eb621e68c4b51 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -48,10 +48,10 @@ def _downgrade_device_registry_deprecation_reports( ) -> Generator[None]: """Keep the deprecated device registry APIs from raising in tests. - async_get_device, the config entry parameters and merge_connections/merge_identifiers - parameters of async_update_device, and via_device on async_get_or_create are - deprecated and raise for core and core integration callers, disable them here so we - can run tests without triggering deprecation errors. + async_get_device, async_is_composite_device_id, the config entry parameters and + merge_connections/merge_identifiers parameters of async_update_device, and via_device + on async_get_or_create are deprecated and raise for core and core integration callers, + disable them here so we can run tests without triggering deprecation errors. Tests which use `mock_integration_frame` will not be affected by this fixture, so they can test the deprecation. @@ -3309,6 +3309,153 @@ async def test_async_is_composite_device_id( assert device_registry.async_is_composite_device_id("unknown_id") is None +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(RuntimeError), + 1, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_async_is_composite_device_id_deprecated( + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test async_is_composite_device_id is deprecated. + + It logs for custom integrations and raises for core and core integrations. Use + async_get with include_composite_devices=False instead. + """ + what = "calls `device_registry.async_is_composite_device_id`" + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + device_registry.async_is_composite_device_id("some_device_id") + + assert caplog.text.count(what) == expected_log + + +async def test_async_get_include_composite_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test async_get gates main, child and composite devices independently.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=entry_1.entry_id, + identifiers={("test", "child")}, + parent_device_id=device_1.id, + name="Child", + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + # By default a composite id resolves to the synthesized composite + composite = device_registry.async_get(old_id) + assert composite is not None + assert composite.id == old_id + assert device_registry.async_get(old_id, include_child_devices=False) == composite + + # include_composite_devices=False resolves a composite id to None, matching + # `old_id in device_registry.devices`, which is composite-blind + assert old_id not in device_registry.devices + assert device_registry.async_get(old_id, include_composite_devices=False) is None + assert ( + device_registry.async_get( + old_id, include_child_devices=False, include_composite_devices=False + ) + is None + ) + + # A registered main device resolves regardless of include_composite_devices + assert ( + device_registry.async_get(device_1.id, include_composite_devices=False).id + == device_1.id + ) + assert ( + device_registry.async_get( + device_1.id, include_child_devices=False, include_composite_devices=False + ).id + == device_1.id + ) + + # An unknown id is None with or without the flag + assert ( + device_registry.async_get("unknown_id", include_composite_devices=False) is None + ) + + # include_main_devices=False, include_child_devices=False resolves only a composite + assert ( + device_registry.async_get( + old_id, include_main_devices=False, include_child_devices=False + ) + == composite + ) + # a registered main device, a child device and an unknown id resolve to None + assert ( + device_registry.async_get( + device_1.id, include_main_devices=False, include_child_devices=False + ) + is None + ) + assert ( + device_registry.async_get( + child_device.id, include_main_devices=False, include_child_devices=False + ) + is None + ) + assert ( + device_registry.async_get( + "unknown_id", include_main_devices=False, include_child_devices=False + ) + is None + ) + + # include_main_devices=False, include_composite_devices=False resolves only a child: + # a composite id resolves to None, a child device still resolves + assert ( + device_registry.async_get( + old_id, include_main_devices=False, include_composite_devices=False + ) + is None + ) + assert ( + device_registry.async_get( + child_device.id, + include_main_devices=False, + include_composite_devices=False, + ) + == child_device + ) + + @pytest.mark.parametrize("load_registries", [False]) async def test_async_get_device_composite_reuses_pre_migration_id( hass: HomeAssistant, hass_storage: dict[str, Any] diff --git a/tests/script/test_gen_recorder_db_versions.py b/tests/script/test_gen_recorder_db_versions.py new file mode 100644 index 00000000000000..fe48e9e0b3bed5 --- /dev/null +++ b/tests/script/test_gen_recorder_db_versions.py @@ -0,0 +1,88 @@ +"""Tests for the gen_recorder_db_versions script.""" + +from datetime import date +import sys +from unittest.mock import patch +import urllib.error + +import pytest + +from script import gen_recorder_db_versions as gen + +# endoflife.date exposes `lts` as a boolean for most cycles, but as the date the +# cycle became LTS for some (e.g. MySQL 8.0), and `eol` as a date string or, when +# no end of life is announced yet, the boolean false. +MARIADB_CYCLES = [ + {"cycle": "12.3", "lts": True, "eol": "2029-06-12"}, # supported LTS + {"cycle": "12.2", "lts": False, "eol": "2026-05-28"}, # newest non-LTS + {"cycle": "11.8", "lts": True, "eol": "2028-06-04"}, # supported LTS + {"cycle": "10.6", "lts": True, "eol": "2026-07-06"}, # LTS past end of life + {"cycle": "10.3", "lts": False, "eol": "2023-05-25"}, # old non-LTS +] +MYSQL_CYCLES = [ + {"cycle": "9.7", "lts": True, "eol": "2034-04-21"}, # supported LTS (bool) + {"cycle": "9.6", "lts": False, "eol": "2026-04-21"}, # newest non-LTS + {"cycle": "8.4", "lts": True, "eol": "2032-04-30"}, # supported LTS (bool) + {"cycle": "8.0", "lts": "2023-07-18", "eol": "2026-04-30"}, # LTS-as-date, past EOL +] + + +@pytest.mark.parametrize( + ("cycles", "expected"), + [ + (MARIADB_CYCLES, {"supported_lts": ["11.8", "12.3"], "latest_non_lts": "12.2"}), + (MYSQL_CYCLES, {"supported_lts": ["8.4", "9.7"], "latest_non_lts": "9.6"}), + ], + ids=["mariadb", "mysql"], +) +def test_engine_versions(cycles: list[dict], expected: dict) -> None: + """Test end-of-life filtering, LTS bool/date handling, and series ordering.""" + assert gen._engine_versions(cycles, date(2026, 8, 20)) == expected + + +def test_eol_handles_missing_and_date() -> None: + """Test a missing end-of-life date maps to date.max and a date string is parsed.""" + assert gen._eol({"eol": False}) == date.max + assert gen._eol({"eol": "2028-02-16"}) == date(2028, 2, 16) + + +def test_render_matches_committed() -> None: + """Test the committed file is exactly what render() produces.""" + assert gen.render(gen.load_committed()) == gen.OUTPUT_FILE.read_text() + + +def test_main_validate_up_to_date() -> None: + """Test validate succeeds when the committed file matches the fetched data.""" + with ( + patch.object(gen, "fetch_versions", return_value=gen.load_committed()), + patch.object(sys, "argv", ["prog", "validate"]), + ): + assert gen.main() == 0 + + +def test_main_validate_out_of_date(capsys: pytest.CaptureFixture[str]) -> None: + """Test validate fails and reports the generated file path when out of date.""" + stale = { + "mariadb": {"supported_lts": ["0.0"], "latest_non_lts": "0.0"}, + "mysql": {"supported_lts": ["0.0"], "latest_non_lts": "0.0"}, + } + with ( + patch.object(gen, "fetch_versions", return_value=stale), + patch.object(sys, "argv", ["prog", "validate"]), + ): + assert gen.main() == 1 + output = capsys.readouterr().out + assert "homeassistant/generated/recorder_database_versions.py" in output + assert "components/recorder/database_versions.py" not in output + + +def test_main_validate_skips_on_network_error( + capsys: pytest.CaptureFixture[str], +) -> None: + """Test validate skips (instead of failing) when endoflife.date is unreachable.""" + with ( + patch.object(gen, "fetch_versions", side_effect=urllib.error.URLError("boom")), + patch.object(sys, "argv", ["prog", "validate"]), + ): + assert gen.main() == 0 + assert "Skipping validation" in capsys.readouterr().out