diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 91798343783d62..7675005226b46c 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -27,9 +27,11 @@ jobs: - name: Generate app token id: token # Pinned to a specific version of the action for security reasons - # v3.2.0 - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: + permission-issues: write + permission-pull-requests: write + app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env] client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env] private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 98df56462fc0a5..27db7fa14ee263 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: exclude_types: [csv, json, html] exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.24.1 + rev: v1.25.2 hooks: - id: zizmor args: diff --git a/homeassistant/components/aidot/manifest.json b/homeassistant/components/aidot/manifest.json index 84e765b8720c76..7c309ae56b247c 100644 --- a/homeassistant/components/aidot/manifest.json +++ b/homeassistant/components/aidot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["python-aidot==0.3.53"] + "requirements": ["python-aidot==0.3.56"] } diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 2881469b968183..f98baf919ea2ee 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -27,16 +27,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bo latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] - # Station Radius is a user-configurable option - distance = entry.options[CONF_RADIUS] - # Reports are published hourly but update twice per hour update_interval = datetime.timedelta(minutes=30) # Setup the Coordinator session = async_get_clientsession(hass) coordinator = AirNowDataUpdateCoordinator( - hass, entry, session, api_key, latitude, longitude, distance, update_interval + hass, entry, session, api_key, latitude, longitude, update_interval ) # Sync with Coordinator @@ -68,13 +65,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version == 1: - new_options = {CONF_RADIUS: entry.data[CONF_RADIUS]} - new_data = entry.data.copy() - del new_data[CONF_RADIUS] + if entry.version < 3: + # The 2026 AirNow API dropped the distance parameter, so the radius + # option no longer affects lookups. Strip it from both older layouts: + # version 1 kept it in data, version 2 in options. + new_data = {k: v for k, v in entry.data.items() if k != CONF_RADIUS} + new_options = {k: v for k, v in entry.options.items() if k != CONF_RADIUS} hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options, version=2 + entry, data=new_data, options=new_options, version=3 ) _LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 3a0dfa49742e73..9940c7fee5e24a 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -7,14 +7,9 @@ from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlowWithReload, -) -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS -from homeassistant.core import HomeAssistant, callback +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -60,7 +55,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: class AirNowConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for AirNow.""" - VERSION = 2 + VERSION = 3 @override async def async_step_user( @@ -90,14 +85,12 @@ async def async_step_user( errors["base"] = "unknown" else: # Create Entry - radius = user_input.pop(CONF_RADIUS) return self.async_create_entry( title=( f"AirNow Sensor at {user_input[CONF_LATITUDE]}," f" {user_input[CONF_LONGITUDE]}" ), data=user_input, - options={CONF_RADIUS: radius}, ) return self.async_show_form( @@ -111,46 +104,12 @@ async def async_step_user( vol.Optional( CONF_LONGITUDE, default=self.hass.config.longitude ): cv.longitude, - vol.Optional(CONF_RADIUS, default=150): vol.All( - int, vol.Range(min=5) - ), } ), description_placeholders={"api_key_url": _API_KEY_URL}, errors=errors, ) - @staticmethod - @callback - @override - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> AirNowOptionsFlowHandler: - """Return the options flow.""" - return AirNowOptionsFlowHandler() - - -class AirNowOptionsFlowHandler(OptionsFlowWithReload): - """Handle an options flow for AirNow.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - if user_input is not None: - return self.async_create_entry(data=user_input) - - options_schema = vol.Schema( - {vol.Optional(CONF_RADIUS): vol.All(int, vol.Range(min=5))} - ) - - return self.async_show_form( - step_id="init", - data_schema=self.add_suggested_values_to_schema( - options_schema, self.config_entry.options - ), - ) - class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index 020aecec00f843..f2a020881b5bf0 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -51,13 +51,11 @@ def __init__( api_key: str, latitude: float, longitude: float, - distance: int, update_interval: timedelta, ) -> None: """Initialize.""" self.latitude = latitude self.longitude = longitude - self.distance = distance self.airnow = WebServiceAPI(api_key, session=session) diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index 6b2a6a0b995b6d..ef921a5cd45199 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_location": "No results found for that location, try changing the location or station radius.", + "invalid_location": "No results found for that location, try changing the location.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { @@ -14,14 +14,12 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", - "longitude": "[%key:common::config_flow::data::longitude%]", - "radius": "Station radius (miles; optional)" + "longitude": "[%key:common::config_flow::data::longitude%]" }, "data_description": { "api_key": "To generate an API key, go to {api_key_url}.", "latitude": "The latitude of your location.", - "longitude": "The longitude of your location.", - "radius": "The radius in miles around your location to search for reporting stations." + "longitude": "The longitude of your location." }, "description": "To generate an API key, go to {api_key_url}." } @@ -40,17 +38,5 @@ } } } - }, - "options": { - "step": { - "init": { - "data": { - "radius": "Station radius (miles)" - }, - "data_description": { - "radius": "The radius in miles around your location to search for reporting stations." - } - } - } } } diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 82fe8b3ce90a12..0c02ba80dc1941 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==14.2.0"] + "requirements": ["aioamazondevices==14.2.2"] } diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 468d2b1b01872b..6134f42e3c453e 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -19,6 +19,7 @@ def async_setup(hass: HomeAssistant) -> bool: websocket_api.async_register_command(hass, websocket_list_composite_splits) websocket_api.async_register_command(hass, websocket_list_devices) + websocket_api.async_register_command(hass, websocket_list_linked_devices) websocket_api.async_register_command(hass, websocket_update_device) websocket_api.async_register_command( hass, websocket_remove_config_entry_from_device @@ -95,6 +96,44 @@ def websocket_list_devices( connection.send_message(msg_json) +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "config/device_registry/list_linked_devices", + vol.Required("device_id"): str, + } +) +def websocket_list_linked_devices( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle list linked devices command. + + Linked devices share at least one connection or identifier with the given + device. Each such connection or identifier is unique within a config entry, so + the linked devices belong to other config entries. + """ + registry = dr.async_get(hass) + device_id = msg["device_id"] + + if (device := registry.async_get(device_id)) is None: + connection.send_error( + msg["id"], websocket_api.ERR_NOT_FOUND, "Device not found" + ) + return + + linked_devices = [ + entry.id + for entry in registry.async_get_devices( + identifiers=device.identifiers, connections=device.connections + ) + if entry.id != device_id + ] + + connection.send_result(msg["id"], {"linked_devices": linked_devices}) + + @require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 9dbb9ce6377a4e..47ddf0fa50d107 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -173,8 +173,10 @@ PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_COMPONENTS = "components" PLACEHOLDER_KEY_FREE_SPACE = "free_space" +PLACEHOLDER_KEY_PORT = "port" PLACEHOLDER_KEY_REASON = "reason" +ISSUE_KEY_ADDON_APP_PORT_CONFLICT = "issue_addon_app_port_conflict" ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 25fccea382db4d..985df7331956fa 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -91,6 +91,7 @@ HASSIO_ISSUES_UPDATE_INTERVAL, HASSIO_MAIN_UPDATE_INTERVAL, HASSIO_STATS_UPDATE_INTERVAL, + ISSUE_KEY_ADDON_APP_PORT_CONFLICT, ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DEPRECATED_ARCH, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, @@ -102,6 +103,7 @@ PLACEHOLDER_KEY_ADDON, PLACEHOLDER_KEY_ADDON_URL, PLACEHOLDER_KEY_FREE_SPACE, + PLACEHOLDER_KEY_PORT, PLACEHOLDER_KEY_REASON, PLACEHOLDER_KEY_REFERENCE, REQUEST_REFRESH_DELAY, @@ -131,6 +133,7 @@ # Keys (type + context) of issues that when found should be made into a repair. ISSUE_KEYS_FOR_REPAIRS = { + ISSUE_KEY_ADDON_APP_PORT_CONFLICT, ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_MOUNT_MOUNT_FAILED, "issue_system_multiple_data_disks", @@ -346,6 +349,7 @@ def _create_or_update_issue_repair(self, issue: Issue) -> None: placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference if issue.key in { + ISSUE_KEY_ADDON_APP_PORT_CONFLICT, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_PWNED, }: @@ -359,6 +363,14 @@ def _create_or_update_issue_repair(self, issue: Issue) -> None: placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME] break + if ( + issue.key == ISSUE_KEY_ADDON_APP_PORT_CONFLICT + and issue.reference_extra + ): + placeholders[PLACEHOLDER_KEY_PORT] = str( + issue.reference_extra["port"] + ) + elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE: host_info = get_host_info(self.hass) if host_info and "disk_free" in host_info: @@ -447,12 +459,14 @@ async def _issue_from_data(self, data: SupervisorIssue) -> Issue | None: type=str(data.type), context=data.context, reference=data.reference, + reference_extra=data.reference_extra, suggestions=[ Suggestion( uuid=suggestion.uuid, type=str(suggestion.type), context=suggestion.context, reference=suggestion.reference, + reference_extra=suggestion.reference_extra, ) for suggestion in suggestions ], diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index ffdbe4befb2908..2dd8969af2b90e 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -14,6 +14,7 @@ class SuggestionDataType(TypedDict): type: str context: str reference: str | None + reference_extra: dict | None @dataclass(slots=True, frozen=True) @@ -24,6 +25,7 @@ class Suggestion: type: str context: ContextType reference: str | None = None + reference_extra: dict | None = field(default=None, hash=False) @property def key(self) -> str: @@ -38,6 +40,7 @@ def from_dict(cls, data: SuggestionDataType) -> Suggestion: type=data["type"], context=ContextType(data["context"]), reference=data["reference"], + reference_extra=data["reference_extra"], ) @@ -48,6 +51,7 @@ class IssueDataType(TypedDict): type: str context: str reference: str | None + reference_extra: dict | None suggestions: NotRequired[list[SuggestionDataType]] @@ -59,6 +63,7 @@ class Issue: type: str context: ContextType reference: str | None = None + reference_extra: dict | None = field(default=None, hash=False) suggestions: list[Suggestion] = field(default_factory=list, compare=False) @property @@ -75,6 +80,7 @@ def from_dict(cls, data: IssueDataType) -> Issue: type=data["type"], context=ContextType(data["context"]), reference=data["reference"], + reference_extra=data["reference_extra"], suggestions=[ Suggestion.from_dict(suggestion) for suggestion in suggestions ], diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index f33ba8a2139263..55b43f3fa08d9e 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -20,6 +20,7 @@ from .const import ( ATTR_SLUG, EXTRA_PLACEHOLDERS, + ISSUE_KEY_ADDON_APP_PORT_CONFLICT, ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DEPRECATED, ISSUE_KEY_ADDON_DEPRECATED_ARCH, @@ -31,6 +32,7 @@ PLACEHOLDER_KEY_ADDON_DOCUMENTATION, PLACEHOLDER_KEY_ADDON_INFO, PLACEHOLDER_KEY_COMPONENTS, + PLACEHOLDER_KEY_PORT, PLACEHOLDER_KEY_REFERENCE, ) from .coordinator import get_issues_info @@ -231,6 +233,19 @@ def description_placeholders(self) -> dict[str, str] | None: return placeholders or None +class AppPortConflictRepairFlow(AddonIssueRepairFlow): + """Handler for app port conflict issue fixing flows.""" + + @property + @override + def description_placeholders(self) -> dict[str, str] | None: + """Get description placeholders for steps.""" + placeholders: dict[str, str] = super().description_placeholders or {} + if self.issue and self.issue.reference_extra: + placeholders[PLACEHOLDER_KEY_PORT] = str(self.issue.reference_extra["port"]) + return placeholders or None + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -245,6 +260,8 @@ async def async_create_fix_flow( return DockerConfigIssueRepairFlow(hass, issue_id) if issue and issue.key == ISSUE_KEY_ADDON_DEPRECATED: return DeprecatedAddonIssueRepairFlow(hass, issue_id) + if issue and issue.key == ISSUE_KEY_ADDON_APP_PORT_CONFLICT: + return AppPortConflictRepairFlow(hass, issue_id) if issue and issue.key in { ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_BOOT_FAIL, diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 1eaac236e789c6..213215822b660c 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -61,6 +61,26 @@ } }, "issues": { + "issue_addon_app_port_conflict": { + "fix_flow": { + "abort": { + "apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details." + }, + "step": { + "addon_execute_start": { + "description": "App {addon} has a port conflict on port {port}. The app cannot change this in settings. Please uninstall the app or stop and reconfigure the other service using port {port}. Once unblocked, select this option again to start the app." + }, + "fix_menu": { + "description": "App {addon} has a port conflict on port {port}. You can clear the port configuration in the app settings to automatically resolve this and start the app, or you can manually unblock the port by stopping or reconfiguring the other service using it.", + "menu_options": { + "addon_clear_port_config": "Clear port configuration", + "addon_execute_start": "Start after manual unblock" + } + } + } + }, + "title": "App port conflict" + }, "issue_addon_boot_fail": { "fix_flow": { "abort": { diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index fbbf65a8d8039b..dca09ab1b8a701 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -1,15 +1,18 @@ """Config flow to configure the HomematicIP Cloud integration.""" from collections.abc import Mapping +import logging from typing import Any, override import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from .const import DOMAIN, HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN, LOGGER +from .const import DOMAIN, HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN from .hap import HomematicipAuth +_LOGGER = logging.getLogger(__name__) + class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for the HomematicIP Cloud integration.""" @@ -43,10 +46,10 @@ async def async_step_init( self.auth = HomematicipAuth(self.hass, user_input) connected = await self.auth.async_setup() if connected: - LOGGER.debug("Connection to HomematicIP Cloud established") + _LOGGER.debug("Connection to HomematicIP Cloud established") return await self.async_step_link() - LOGGER.debug("Connection to HomematicIP Cloud failed") + _LOGGER.debug("Connection to HomematicIP Cloud failed") errors["base"] = "invalid_sgtin_or_pin" return self.async_show_form( @@ -69,7 +72,7 @@ async def async_step_link(self, user_input: None = None) -> ConfigFlowResult: if pressed: authtoken = await self.auth.async_register() if authtoken: - LOGGER.debug("Write config entry for HomematicIP Cloud") + _LOGGER.debug("Write config entry for HomematicIP Cloud") if self.source == "reauth": return self.async_update_reload_and_abort( self._get_reauth_entry(), @@ -136,7 +139,7 @@ async def async_step_import(self, import_data: dict[str, str]) -> ConfigFlowResu await self.async_set_unique_id(hapid) self._abort_if_unique_id_configured() - LOGGER.debug("Imported authentication for %s", hapid) + _LOGGER.debug("Imported authentication for %s", hapid) return self.async_create_entry( title=hapid, data={HMIPC_AUTHTOKEN: authtoken, HMIPC_HAPID: hapid, HMIPC_NAME: name}, diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index d298254377fc38..d9046de8de58e0 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -1,11 +1,7 @@ """Constants for the HomematicIP Cloud integration.""" -import logging - from homeassistant.const import Platform -LOGGER = logging.getLogger(".") - DOMAIN = "homematicip_cloud" PLATFORMS = [ diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 9b6947a99e6255..f45cb58301228c 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==8.2.0"] + "requirements": ["infrared-protocols==8.2.1"] } diff --git a/homeassistant/components/midea/manifest.json b/homeassistant/components/midea/manifest.json index cd12a2730cb7bf..a089db203cff0b 100644 --- a/homeassistant/components/midea/manifest.json +++ b/homeassistant/components/midea/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["midealocal"], "quality_scale": "bronze", - "requirements": ["midea-local==6.10.0"] + "requirements": ["midea-local==6.11.1"] } diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 171ffbf2128b8e..db71f28b0ea5f4 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -256,6 +256,10 @@ def __init__(self, device: Device) -> None: super().__init__(device) self._webrtc_sessions: dict[str, WebRtcStream] = {} self._refresh_unsub: dict[str, Callable[[], None]] = {} + # The bundled placeholder is a PNG; the camera platform would otherwise + # serve it as its default image/jpeg, corrupting the frame for any + # client that trusts the Content-Type header. + self.content_type = "image/png" async def _async_refresh_stream(self, session_id: str) -> datetime.datetime | None: """Refresh stream to extend expiration time.""" diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index a6c177b44096da..bb619b8fede528 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -2,6 +2,10 @@ "common": { "condition_threshold_name": "Threshold type", "trigger_for_name": "For at least", + "trigger_offset_description": "Time to offset the trigger from the solar event.", + "trigger_offset_name": "Offset", + "trigger_offset_type_description": "Whether to trigger before or after the solar event.", + "trigger_offset_type_name": "Offset type", "trigger_threshold_name": "Threshold type", "twilight_type_description": "The phase of twilight.", "twilight_type_name": "Twilight type" @@ -95,6 +99,12 @@ } }, "selector": { + "trigger_offset_type": { + "options": { + "after": "After", + "before": "Before" + } + }, "twilight_type": { "options": { "any": "Any", @@ -109,6 +119,14 @@ "dawn": { "description": "Triggers at dawn, when civil, nautical, or astronomical twilight begins.", "fields": { + "offset": { + "description": "[%key:component::sun::common::trigger_offset_description%]", + "name": "[%key:component::sun::common::trigger_offset_name%]" + }, + "offset_type": { + "description": "[%key:component::sun::common::trigger_offset_type_description%]", + "name": "[%key:component::sun::common::trigger_offset_type_name%]" + }, "type": { "description": "[%key:component::sun::common::twilight_type_description%]", "name": "[%key:component::sun::common::twilight_type_name%]" @@ -119,6 +137,14 @@ "dusk": { "description": "Triggers at dusk, when civil, nautical, or astronomical twilight ends.", "fields": { + "offset": { + "description": "[%key:component::sun::common::trigger_offset_description%]", + "name": "[%key:component::sun::common::trigger_offset_name%]" + }, + "offset_type": { + "description": "[%key:component::sun::common::trigger_offset_type_description%]", + "name": "[%key:component::sun::common::trigger_offset_type_name%]" + }, "type": { "description": "[%key:component::sun::common::twilight_type_description%]", "name": "[%key:component::sun::common::twilight_type_name%]" @@ -149,18 +175,58 @@ }, "solar_midnight": { "description": "Triggers when the sun reaches its lowest point.", + "fields": { + "offset": { + "description": "[%key:component::sun::common::trigger_offset_description%]", + "name": "[%key:component::sun::common::trigger_offset_name%]" + }, + "offset_type": { + "description": "[%key:component::sun::common::trigger_offset_type_description%]", + "name": "[%key:component::sun::common::trigger_offset_type_name%]" + } + }, "name": "Solar midnight" }, "solar_noon": { "description": "Triggers when the sun reaches its highest point.", + "fields": { + "offset": { + "description": "[%key:component::sun::common::trigger_offset_description%]", + "name": "[%key:component::sun::common::trigger_offset_name%]" + }, + "offset_type": { + "description": "[%key:component::sun::common::trigger_offset_type_description%]", + "name": "[%key:component::sun::common::trigger_offset_type_name%]" + } + }, "name": "Solar noon" }, "sunrise": { "description": "Triggers when the sun rises.", + "fields": { + "offset": { + "description": "[%key:component::sun::common::trigger_offset_description%]", + "name": "[%key:component::sun::common::trigger_offset_name%]" + }, + "offset_type": { + "description": "[%key:component::sun::common::trigger_offset_type_description%]", + "name": "[%key:component::sun::common::trigger_offset_type_name%]" + } + }, "name": "Sunrise" }, "sunset": { "description": "Triggers when the sun sets.", + "fields": { + "offset": { + "description": "[%key:component::sun::common::trigger_offset_description%]", + "name": "[%key:component::sun::common::trigger_offset_name%]" + }, + "offset_type": { + "description": "[%key:component::sun::common::trigger_offset_type_description%]", + "name": "[%key:component::sun::common::trigger_offset_type_name%]" + } + }, "name": "Sunset" } } diff --git a/homeassistant/components/sun/trigger.py b/homeassistant/components/sun/trigger.py index 4eacc55a58c001..6ffd759d752a3b 100644 --- a/homeassistant/components/sun/trigger.py +++ b/homeassistant/components/sun/trigger.py @@ -64,6 +64,19 @@ _TWILIGHT_NAUTICAL = "nautical" _TWILIGHT_ASTRONOMICAL = "astronomical" +CONF_OFFSET_TYPE = "offset_type" +OFFSET_TYPE_BEFORE = "before" +OFFSET_TYPE_AFTER = "after" + +# Offset options shared by the solar event triggers. A positive offset combined +# with an offset type of "before" fires earlier than the event; "after" later. +_OFFSET_OPTIONS: dict[vol.Marker, Any] = { + vol.Required(CONF_OFFSET, default=timedelta(0)): cv.time_period, + vol.Required(CONF_OFFSET_TYPE, default=OFFSET_TYPE_BEFORE): vol.In( + {OFFSET_TYPE_BEFORE, OFFSET_TYPE_AFTER} + ), +} + # Sun elevation at each twilight boundary. _TWILIGHT_ELEVATIONS = { _TWILIGHT_CIVIL: ELEVATION_CIVIL, @@ -134,7 +147,9 @@ class SunElevationCrossedTrigger( _schema = _ELEVATION_CROSSED_TRIGGER_SCHEMA -_EVENT_TRIGGER_SCHEMA = vol.Schema({vol.Required(CONF_OPTIONS, default=dict): {}}) +_EVENT_TRIGGER_SCHEMA = vol.Schema( + {vol.Required(CONF_OPTIONS, default=dict): {**_OFFSET_OPTIONS}} +) class SunEventTrigger(Trigger): @@ -155,10 +170,16 @@ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize the trigger.""" super().__init__(hass, config) self._options = config.options or {} + offset = self._options.get(CONF_OFFSET) or timedelta(0) + if self._options.get(CONF_OFFSET_TYPE) == OFFSET_TYPE_BEFORE: + offset = -offset + self._offset = offset def _get_next_event(self, utc_point_in_time: datetime) -> datetime: """Return the next time this solar event occurs.""" - return get_astral_event_next(self._hass, self._event, utc_point_in_time) + return get_astral_event_next( + self._hass, self._event, utc_point_in_time, self._offset + ) def _action_payload(self) -> dict[str, Any]: """Return extra trigger payload passed to the action.""" @@ -235,6 +256,7 @@ class SolarMidnightTrigger(SunEventTrigger): vol.Optional(CONF_TYPE, default=_TWILIGHT_CIVIL): vol.In( _TWILIGHT_ELEVATIONS ), + **_OFFSET_OPTIONS, } } ) @@ -257,6 +279,7 @@ def _get_next_event(self, utc_point_in_time: datetime) -> datetime: get_astral_observer(self._hass), self._event, utc_point_in_time, + self._offset, # astral takes a depression (degrees below the horizon), i.e. the # negated elevation. depression=-self._elevation, @@ -305,13 +328,6 @@ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize the trigger.""" super().__init__(hass, config) self._event = self._options[CONF_EVENT] - self._offset: timedelta = self._options[CONF_OFFSET] - - @override - def _get_next_event(self, utc_point_in_time: datetime) -> datetime: - return get_astral_event_next( - self._hass, self._event, utc_point_in_time, self._offset - ) @override def _action_payload(self) -> dict[str, Any]: diff --git a/homeassistant/components/sun/triggers.yaml b/homeassistant/components/sun/triggers.yaml index c51ffa942fdf5b..7342180247ab7e 100644 --- a/homeassistant/components/sun/triggers.yaml +++ b/homeassistant/components/sun/triggers.yaml @@ -15,6 +15,27 @@ selector: duration: +.offset: &trigger_offset + offset: + required: true + default: + days: 0 + hours: 0 + minutes: 0 + seconds: 0 + selector: + duration: + enable_day: true + offset_type: + required: true + default: before + selector: + select: + translation_key: trigger_offset_type + options: + - before + - after + .elevation_threshold_entity: &trigger_elevation_threshold_entity - domain: input_number unit_of_measurement: "°" @@ -29,18 +50,31 @@ mode: box unit_of_measurement: "°" -sunrise: {} -sunset: {} -solar_noon: {} -solar_midnight: {} +sunrise: + fields: + <<: *trigger_offset + +sunset: + fields: + <<: *trigger_offset + +solar_noon: + fields: + <<: *trigger_offset + +solar_midnight: + fields: + <<: *trigger_offset dawn: fields: type: *twilight_type + <<: *trigger_offset dusk: fields: type: *twilight_type + <<: *trigger_offset elevation_changed: fields: diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 72dd2ed7f0ef18..f4b04bc9ce99f9 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -1,13 +1,12 @@ """The vizio component.""" -from pyvizio import VizioAsync +from vizaio import Vizio from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, CONF_HOST, - CONF_NAME, Platform, ) from homeassistant.core import HomeAssistant @@ -17,7 +16,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey -from .const import DEFAULT_TIMEOUT, DEVICE_ID, DOMAIN, VIZIO_DEVICE_CLASSES +from .const import DEFAULT_TIMEOUT, DOMAIN, VIZIO_DEVICE_CLASSES from .coordinator import ( VizioAppsDataUpdateCoordinator, VizioConfigEntry, @@ -45,12 +44,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VizioConfigEntry) -> boo device_class = entry.data[CONF_DEVICE_CLASS] # Create device - device = VizioAsync( - DEVICE_ID, + device = Vizio( host, - entry.data[CONF_NAME], - auth_token=token, device_type=VIZIO_DEVICE_CLASSES[device_class], + auth_token=token, session=async_get_clientsession(hass, False), timeout=DEFAULT_TIMEOUT, ) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index d8c1979fc0ed59..bf05be04a6f80e 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -4,8 +4,8 @@ import logging from typing import Any, override -from pyvizio import VizioAsync, async_guess_device_type -from pyvizio.const import APP_HOME, APPS +from vizaio import AppRecord, PairChallenge, Vizio, VizioError, async_is_tv +from vizaio.apps import APP_HOME, BUNDLED_APPS import voluptuous as vol from homeassistant.components.media_player import MediaPlayerDeviceClass @@ -24,7 +24,7 @@ CONF_NAME, CONF_PIN, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -40,6 +40,7 @@ DEFAULT_VOLUME_STEP, DEVICE_ID, DOMAIN, + VIZIO_DEVICE_CLASSES, ) from .coordinator import VizioConfigEntry @@ -93,16 +94,56 @@ def _get_pairing_schema(input_dict: dict[str, Any] | None = None) -> vol.Schema: ) +def _get_device( + hass: HomeAssistant, + host: str, + device_class: str, + auth_token: str | None = None, +) -> Vizio: + """Build a client for config flow validation calls.""" + return Vizio( + host, + device_type=VIZIO_DEVICE_CLASSES[MediaPlayerDeviceClass(device_class)], + auth_token=auth_token, + session=async_get_clientsession(hass, False), + ) + + +async def _async_get_unique_id( + hass: HomeAssistant, host: str, device_class: str +) -> str | None: + """Return the device serial number, or None if unavailable.""" + try: + return await _get_device(hass, host, device_class).get_serial_number() + except VizioError: + return None + + +async def _async_validate_config( + hass: HomeAssistant, host: str, auth_token: str | None, device_class: str +) -> bool: + """Return whether the device is reachable (and the token valid, if any).""" + device = _get_device(hass, host, device_class, auth_token) + try: + if auth_token: + await device.ping_auth() + else: + await device.ping() + except VizioError: + return False + return True + + class VizioOptionsConfigFlow(OptionsFlow): """Handle Vizio options.""" - def _get_app_list(self) -> list[dict[str, Any]]: + def _get_app_list(self) -> tuple[AppRecord, ...]: """Return the current apps list, falling back to defaults.""" if ( apps_coordinator := self.hass.data.get(DATA_APPS) ) and apps_coordinator.data: return apps_coordinator.data - return APPS + return BUNDLED_APPS async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -154,8 +195,8 @@ async def async_step_init( ), ): cv.multi_select( [ - APP_HOME["name"], - *(app["name"] for app in self._get_app_list()), + APP_HOME.name, + *(app.name for app in self._get_app_list()), ] ), } @@ -182,8 +223,7 @@ def __init__(self) -> None: """Initialize config flow.""" self._user_schema: vol.Schema | None = None self._must_show_form: bool | None = None - self._ch_type: str | None = None - self._pairing_token: str | None = None + self._pair_challenge: PairChallenge | None = None self._data: dict[str, Any] | None = None self._apps: dict[str, list] = {} @@ -209,10 +249,8 @@ async def async_step_user( # Store current values in case setup fails and user needs to edit self._user_schema = _get_config_schema(user_input) if self.unique_id is None: - unique_id = await VizioAsync.get_unique_id( - user_input[CONF_HOST], - user_input[CONF_DEVICE_CLASS], - session=async_get_clientsession(self.hass, False), + unique_id = await _async_get_unique_id( + self.hass, user_input[CONF_HOST], user_input[CONF_DEVICE_CLASS] ) # Check if unique ID was found, set unique ID, and abort if a flow with @@ -238,11 +276,11 @@ async def async_step_user( CONF_ACCESS_TOKEN ): # Ensure config is valid for a device - if not await VizioAsync.validate_ha_config( + if not await _async_validate_config( + self.hass, user_input[CONF_HOST], user_input.get(CONF_ACCESS_TOKEN), user_input[CONF_DEVICE_CLASS], - session=async_get_clientsession(self.hass, False), ): errors["base"] = "cannot_connect" @@ -270,14 +308,14 @@ async def async_step_zeroconf( num_chars_to_strip = len(discovery_info.type) + 1 name = discovery_info.name[:-num_chars_to_strip] - device_class = await async_guess_device_type(host) + device_class = ( + MediaPlayerDeviceClass.TV + if await async_is_tv(host) + else MediaPlayerDeviceClass.SPEAKER + ) # Set unique ID early for discovery flow so we can abort if needed - unique_id = await VizioAsync.get_unique_id( - host, - device_class, - session=async_get_clientsession(self.hass, False), - ) + unique_id = await _async_get_unique_id(self.hass, host, device_class) if not unique_id: return self.async_abort(reason="cannot_connect") @@ -307,51 +345,41 @@ async def async_step_pair_tv( assert self._data # Start pairing process if it hasn't already started - if not self._ch_type and not self._pairing_token: - dev = VizioAsync( - DEVICE_ID, - self._data[CONF_HOST], - self._data[CONF_NAME], - None, - self._data[CONF_DEVICE_CLASS], - session=async_get_clientsession(self.hass, False), - ) - pair_data = await dev.start_pair() - - if pair_data: - self._ch_type = pair_data.ch_type - self._pairing_token = pair_data.token - return await self.async_step_pair_tv() - - return self.async_show_form( - step_id="user", - data_schema=_get_config_schema(self._data), - errors={"base": "cannot_connect"}, + if not self._pair_challenge: + dev = _get_device( + self.hass, self._data[CONF_HOST], self._data[CONF_DEVICE_CLASS] ) + try: + self._pair_challenge = await dev.begin_pair( + device_id=DEVICE_ID, device_name=self._data[CONF_NAME] + ) + except VizioError: + return self.async_show_form( + step_id="user", + data_schema=_get_config_schema(self._data), + errors={"base": "cannot_connect"}, + ) + return await self.async_step_pair_tv() # Complete pairing process if PIN has been provided if user_input and user_input.get(CONF_PIN): - dev = VizioAsync( - DEVICE_ID, - self._data[CONF_HOST], - self._data[CONF_NAME], - None, - self._data[CONF_DEVICE_CLASS], - session=async_get_clientsession(self.hass, False), + dev = _get_device( + self.hass, self._data[CONF_HOST], self._data[CONF_DEVICE_CLASS] ) - pair_data = await dev.pair( - self._ch_type, self._pairing_token, user_input[CONF_PIN] - ) - - if pair_data: - self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token + try: + auth_token = await dev.finish_pair( + device_id=DEVICE_ID, + challenge=self._pair_challenge, + pin=user_input[CONF_PIN], + ) + except VizioError: + # If pairing failed, it's assumed the PIN was invalid + errors[CONF_PIN] = "complete_pairing_failed" + else: + self._data[CONF_ACCESS_TOKEN] = auth_token self._must_show_form = True return await self.async_step_pairing_complete() - # If no data was retrieved, it's assumed that the pairing attempt was not - # successful - errors[CONF_PIN] = "complete_pairing_failed" - return self.async_show_form( step_id="pair_tv", data_schema=_get_pairing_schema(user_input), diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index fafccf6492d9cd..101d6e6d919543 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -1,9 +1,6 @@ """Constants used by vizio component.""" -from pyvizio.const import ( - DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER, - DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV, -) +from vizaio import DeviceType from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -55,10 +52,9 @@ VIZIO_VOLUME = "volume" VIZIO_MUTE = "mute" -# Since Vizio component relies on device class, this dict will ensure that changes to -# the values of DEVICE_CLASS_SPEAKER or DEVICE_CLASS_TV -# don't require changes to pyvizio. +# Maps HA device class to the vizaio device type so changes to vizaio's +# DeviceType values never require a config entry migration. VIZIO_DEVICE_CLASSES = { - MediaPlayerDeviceClass.SPEAKER: VIZIO_DEVICE_CLASS_SPEAKER, - MediaPlayerDeviceClass.TV: VIZIO_DEVICE_CLASS_TV, + MediaPlayerDeviceClass.SPEAKER: DeviceType.SOUNDBAR, + MediaPlayerDeviceClass.TV: DeviceType.TV, } diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index b0aba890706bb1..2394be239c975c 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -1,15 +1,24 @@ """Coordinator for the vizio component.""" -from dataclasses import dataclass +from collections.abc import Coroutine +from dataclasses import asdict, dataclass from datetime import timedelta import logging from typing import TYPE_CHECKING, Any, override -from pyvizio import VizioAsync -from pyvizio.api.apps import AppConfig -from pyvizio.api.input import InputItem -from pyvizio.const import APPS, INPUT_APPS -from pyvizio.util import gen_apps_list_from_url +from vizaio import ( + AppAvailability, + AppConfig, + AppRecord, + InputInfo, + SettingInfo, + Vizio, + VizioError, + fetch_app_availability, + fetch_remote_app_catalog, + is_app_input, +) +from vizaio.apps import BUNDLED_APPS, BUNDLED_AVAILABILITY from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.config_entries import ConfigEntry @@ -29,6 +38,44 @@ SCAN_INTERVAL = timedelta(seconds=30) +async def _optional[T](coro: Coroutine[Any, Any, T]) -> T | None: + """Return the call result, or None when the device API call fails.""" + try: + return await coro + except VizioError: + return None + + +def _records_to_storage(records: tuple[AppRecord, ...]) -> list[dict[str, Any]]: + """Serialize AppRecords for the store.""" + return [asdict(record) for record in records] + + +def _records_from_storage( + data: list[dict[str, Any]], +) -> tuple[AppRecord, ...] | None: + """Deserialize stored AppRecords, or None if the data is unreadable. + + Data stored by the previous pyvizio-based version has a different + shape (uppercase config keys) and is discarded; the next daily + refresh replaces it. + """ + try: + return tuple( + AppRecord( + name=item["name"], + country=tuple(item["country"]), + config=tuple(AppConfig(**config) for config in item["config"]), + id=item["id"], + description=item["description"], + icon_url=item["icon_url"], + ) + for item in data + ) + except KeyError, TypeError: + return None + + @dataclass(frozen=True) class VizioRuntimeData: """Runtime data for Vizio integration.""" @@ -43,17 +90,17 @@ class VizioDeviceData: # Power state is_on: bool - # Audio settings from get_all_settings("audio") - audio_settings: dict[str, Any] | None = None + # Audio settings from get_settings("audio") + audio_settings: dict[str, SettingInfo] | None = None - # Sound mode options from get_setting_options("audio", "eq") + # Sound mode options from get_setting("audio", "eq") sound_mode_list: list[str] | None = None # Current input from get_current_input() current_input: str | None = None - # Available inputs from get_inputs_list() - input_list: list[InputItem] | None = None + # Available inputs from get_inputs() + input_list: list[InputInfo] | None = None # Current app config from get_current_app_config() (TVs only) current_app_config: AppConfig | None = None @@ -68,7 +115,7 @@ def __init__( self, hass: HomeAssistant, config_entry: VizioConfigEntry, - device: VizioAsync, + device: Vizio, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -83,8 +130,8 @@ def __init__( @override async def _async_setup(self) -> None: """Fetch device info and update device registry.""" - model = await self.device.get_model_name(log_api_exception=False) - version = await self.device.get_version(log_api_exception=False) + model = await _optional(self.device.get_model_name()) + version = await _optional(self.device.get_version()) if TYPE_CHECKING: assert self.config_entry.unique_id @@ -102,40 +149,38 @@ async def _async_setup(self) -> None: @override async def _async_update_data(self) -> VizioDeviceData: """Fetch all device data.""" - is_on = await self.device.get_power_state(log_api_exception=False) - - if is_on is None: + try: + is_on = await self.device.get_power_state() + except VizioError as err: raise UpdateFailed( f"Unable to connect to {self.config_entry.data[CONF_HOST]}" - ) + ) from err if not is_on: return VizioDeviceData(is_on=False) # Device is on - fetch all data - audio_settings = await self.device.get_all_settings( - VIZIO_AUDIO_SETTINGS, log_api_exception=False - ) + audio_settings = await _optional(self.device.get_settings(VIZIO_AUDIO_SETTINGS)) sound_mode_list = None if audio_settings and VIZIO_SOUND_MODE in audio_settings: - sound_mode_list = await self.device.get_setting_options( - VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, log_api_exception=False + sound_mode = await _optional( + self.device.get_setting(VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE) ) + if sound_mode: + sound_mode_list = list(sound_mode.options) - current_input = await self.device.get_current_input(log_api_exception=False) - input_list = await self.device.get_inputs_list(log_api_exception=False) + current_input = await _optional(self.device.get_current_input()) + input_list = await _optional(self.device.get_inputs()) current_app_config = None # Only attempt to fetch app config if the device is a TV and supports apps if ( self.config_entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV and input_list - and any(input_item.name in INPUT_APPS for input_item in input_list) + and any(is_app_input(input_item.name) for input_item in input_list) ): - current_app_config = await self.device.get_current_app_config( - log_api_exception=False - ) + current_app_config = await _optional(self.device.get_current_app_config()) return VizioDeviceData( is_on=True, @@ -147,7 +192,7 @@ async def _async_update_data(self) -> VizioDeviceData: ) -class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): +class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[tuple[AppRecord, ...]]): """Define an object to hold Vizio app config data.""" def __init__( @@ -166,38 +211,44 @@ def __init__( self.fail_count = 0 self.fail_threshold = 10 self.store = store + self.availability: tuple[AppAvailability, ...] = BUNDLED_AVAILABILITY async def async_setup(self) -> None: """Load initial data from storage and register shutdown.""" await self.async_register_shutdown() - self.data = await self.store.async_load() or APPS + stored = await self.store.async_load() + self.data = (_records_from_storage(stored) if stored else None) or BUNDLED_APPS @override - async def _async_update_data(self) -> list[dict[str, Any]]: + async def _async_update_data(self) -> tuple[AppRecord, ...]: """Update data via library.""" - if data := await gen_apps_list_from_url( - session=async_get_clientsession(self.hass) - ): - # Reset the fail count and threshold when the data is successfully retrieved - self.fail_count = 0 - self.fail_threshold = 10 - # Store the new data if it has changed so we have it for the next restart - if data != self.data: - await self.store.async_save(data) - return data - # For every failure, increase the fail count until we reach the threshold. - # We then log a warning, increase the threshold, and reset the fail count. - # This is here to prevent silent failures but to reduce repeat logs. - if self.fail_count == self.fail_threshold: - _LOGGER.warning( - ( - "Unable to retrieve the apps list from the external server for the " - "last %s days" - ), - self.fail_threshold, - ) - self.fail_count = 0 - self.fail_threshold += 10 - else: - self.fail_count += 1 - return self.data + session = async_get_clientsession(self.hass) + # Availability complements the catalog for app-name resolution; it has + # its own bundled fallback and is not persisted. + self.availability = await fetch_app_availability(session) + try: + data = await fetch_remote_app_catalog(session) + except VizioError: + # For every failure, increase the fail count until we reach the threshold. + # We then log a warning, increase the threshold, and reset the fail count. + # This is here to prevent silent failures but to reduce repeat logs. + if self.fail_count == self.fail_threshold: + _LOGGER.warning( + ( + "Unable to retrieve the apps list from the external server " + "for the last %s days" + ), + self.fail_threshold, + ) + self.fail_count = 0 + self.fail_threshold += 10 + else: + self.fail_count += 1 + return self.data + # Reset the fail count and threshold when the data is successfully retrieved + self.fail_count = 0 + self.fail_threshold = 10 + # Store the new data if it has changed so we have it for the next restart + if data != self.data: + await self.store.async_save(_records_to_storage(data)) + return data diff --git a/homeassistant/components/vizio/helpers.py b/homeassistant/components/vizio/helpers.py new file mode 100644 index 00000000000000..44092c7cb64455 --- /dev/null +++ b/homeassistant/components/vizio/helpers.py @@ -0,0 +1,22 @@ +"""Helpers for the vizio integration.""" + +from collections.abc import Coroutine +from typing import Any + +from vizaio import VizioError + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + + +async def async_device_command[T](coro: Coroutine[Any, Any, T]) -> T: + """Run a device command, raising HomeAssistantError on API failure.""" + try: + return await coro + except VizioError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 6937b3000e3e9e..0367e9d001ec27 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/vizio", "integration_type": "device", "iot_class": "local_polling", - "loggers": ["pyvizio"], - "requirements": ["pyvizio==0.1.64"], + "loggers": ["vizaio"], + "requirements": ["vizaio==0.3.2"], "zeroconf": ["_viziocast._tcp.local."] } diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index a4d997503fbd52..1b342767866f8d 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -2,8 +2,14 @@ from typing import Any, override -from pyvizio.api.apps import AppConfig, find_app_name -from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP +from vizaio import AppConfig, AppRecord, RemoteKey +from vizaio.apps import ( + APP_HOME, + NO_APP_RUNNING, + UNKNOWN_APP, + find_app_name, + is_app_input, +) from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -20,7 +26,11 @@ from . import DATA_APPS from .const import ( CONF_ADDITIONAL_CONFIGS, + CONF_APP_ID, CONF_APPS, + CONF_CONFIG, + CONF_MESSAGE, + CONF_NAME_SPACE, CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP, DOMAIN, @@ -36,6 +46,7 @@ VizioConfigEntry, VizioDeviceCoordinator, ) +from .helpers import async_device_command PARALLEL_UPDATES = 0 @@ -94,6 +105,15 @@ async def async_setup_entry( async_add_entities([entity]) +def _app_config_from_conf(config: dict[str, Any]) -> AppConfig: + """Convert a stored uppercase-key app config to a vizaio AppConfig.""" + return AppConfig( + app_id=str(config[CONF_APP_ID]), + name_space=int(config[CONF_NAME_SPACE]), + message=config.get(CONF_MESSAGE), + ) + + class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity): """Media Player implementation which performs REST requests to device.""" @@ -123,7 +143,10 @@ def __init__( CONF_ADDITIONAL_CONFIGS, [] ) self._device = coordinator.device - self._max_volume = float(coordinator.device.get_max_volume()) + if apps_coordinator: + self._device.set_app_catalog(apps_coordinator.data) + self._device.set_app_availability(apps_coordinator.availability) + self._max_volume = float(self._device.profile.max_volume) # Entity class attributes that will change with each update (we only include # the ones that are initialized differently from the defaults) @@ -180,11 +203,11 @@ def _handle_coordinator_update(self) -> None: # Audio settings if data.audio_settings: self._attr_volume_level = ( - float(data.audio_settings[VIZIO_VOLUME]) / self._max_volume + float(data.audio_settings[VIZIO_VOLUME].value) / self._max_volume ) if VIZIO_MUTE in data.audio_settings: self._attr_is_volume_muted = ( - data.audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON + str(data.audio_settings[VIZIO_MUTE].value).lower() == VIZIO_MUTE_ON ) else: self._attr_is_volume_muted = None @@ -192,7 +215,7 @@ def _handle_coordinator_update(self) -> None: self._attr_supported_features |= ( MediaPlayerEntityFeature.SELECT_SOUND_MODE ) - self._attr_sound_mode = data.audio_settings[VIZIO_SOUND_MODE] + self._attr_sound_mode = str(data.audio_settings[VIZIO_SOUND_MODE].value) if not self._attr_sound_mode_list: self._attr_sound_mode_list = data.sound_mode_list or [] else: @@ -210,17 +233,28 @@ def _handle_coordinator_update(self) -> None: if ( self._attr_device_class == MediaPlayerDeviceClass.TV and self._available_inputs - and any(app in self._available_inputs for app in INPUT_APPS) + and any(is_app_input(name) for name in self._available_inputs) ): all_apps = self._all_apps or () - self._available_apps = self._apps_list([app["name"] for app in all_apps]) + self._available_apps = self._apps_list([app.name for app in all_apps]) self._current_app_config = data.current_app_config - self._attr_app_name = find_app_name( + app_name = find_app_name( self._current_app_config, - [APP_HOME, *all_apps, *self._additional_app_configs], + [APP_HOME, *all_apps, *self._additional_app_records()], + availability=( + self._apps_coordinator.availability + if self._apps_coordinator + else () + ), ) - if self._attr_app_name == NO_APP_RUNNING: + # find_app_name returns None on a catalog miss; the app_name state + # attribute contract expects the UNKNOWN_APP sentinel instead + if app_name == NO_APP_RUNNING: self._attr_app_name = None + elif app_name is None: + self._attr_app_name = UNKNOWN_APP + else: + self._attr_app_name = app_name super()._handle_coordinator_update() @@ -230,15 +264,23 @@ def _get_additional_app_names(self) -> list[str]: additional_app["name"] for additional_app in self._additional_app_configs ] + def _additional_app_records(self) -> list[AppRecord]: + """Return AppRecords for additional apps from configuration.yaml.""" + return [ + AppRecord( + name=app["name"], + country=("*",), + config=(_app_config_from_conf(app[CONF_CONFIG]),), + ) + for app in self._additional_app_configs + ] + async def async_update_setting( self, setting_type: str, setting_name: str, new_value: int | str ) -> None: """Update a setting when update_setting service is called.""" - await self._device.set_setting( - setting_type, - setting_name, - new_value, - log_api_exception=False, + await async_device_command( + self._device.set_setting(setting_type, setting_name, new_value) ) @override @@ -262,6 +304,8 @@ async def _async_write_state(*_: Any) -> None: def apps_list_update() -> None: """Update list of all apps.""" self._all_apps = apps_coordinator.data + self._device.set_app_catalog(apps_coordinator.data) + self._device.set_app_availability(apps_coordinator.availability) self.async_write_ha_state() self.async_on_remove(apps_coordinator.async_add_listener(apps_list_update)) @@ -270,7 +314,11 @@ def apps_list_update() -> None: @override def source(self) -> str | None: """Return current input of the device.""" - if self._attr_app_name is not None and self._current_input in INPUT_APPS: + if ( + self._attr_app_name is not None + and self._current_input is not None + and is_app_input(self._current_input) + ): return self._attr_app_name return self._current_input @@ -286,7 +334,7 @@ def source_list(self) -> list[str]: *( _input for _input in self._available_inputs - if _input not in INPUT_APPS + if not is_app_input(_input) ), *self._available_apps, *( @@ -301,12 +349,12 @@ def source_list(self) -> list[str]: @property @override def app_id(self): - """Return the ID of the current app if it is unknown by pyvizio.""" + """Return the ID of the current app if it is unknown by vizaio.""" if self._current_app_config and self.source == UNKNOWN_APP: return { - "APP_ID": self._current_app_config.APP_ID, - "NAME_SPACE": self._current_app_config.NAME_SPACE, - "MESSAGE": self._current_app_config.MESSAGE, + CONF_APP_ID: self._current_app_config.app_id, + CONF_NAME_SPACE: self._current_app_config.name_space, + CONF_MESSAGE: self._current_app_config.message, } return None @@ -315,66 +363,66 @@ def app_id(self): async def async_select_sound_mode(self, sound_mode: str) -> None: """Select sound mode.""" if sound_mode in (self._attr_sound_mode_list or ()): - await self._device.set_setting( - VIZIO_AUDIO_SETTINGS, - VIZIO_SOUND_MODE, - sound_mode, - log_api_exception=False, + await async_device_command( + self._device.set_setting( + VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, sound_mode + ) ) @override async def async_turn_on(self) -> None: """Turn the device on.""" - await self._device.pow_on(log_api_exception=False) + await async_device_command(self._device.power_on()) @override async def async_turn_off(self) -> None: """Turn the device off.""" - await self._device.pow_off(log_api_exception=False) + await async_device_command(self._device.power_off()) @override async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" if mute: - await self._device.mute_on(log_api_exception=False) + await async_device_command(self._device.mute()) self._attr_is_volume_muted = True else: - await self._device.mute_off(log_api_exception=False) + await async_device_command(self._device.unmute()) self._attr_is_volume_muted = False @override async def async_media_previous_track(self) -> None: """Send previous channel command.""" - await self._device.ch_down(log_api_exception=False) + await async_device_command(self._device.send_key(RemoteKey.CH_DOWN)) @override async def async_media_next_track(self) -> None: """Send next channel command.""" - await self._device.ch_up(log_api_exception=False) + await async_device_command(self._device.send_key(RemoteKey.CH_UP)) @override async def async_select_source(self, source: str) -> None: """Select input source.""" if source in self._available_inputs: - await self._device.set_input(source, log_api_exception=False) + await async_device_command(self._device.set_input(source)) elif source in self._get_additional_app_names(): - await self._device.launch_app_config( - **next( - app["config"] - for app in self._additional_app_configs - if app["name"] == source - ), - log_api_exception=False, + await async_device_command( + self._device.launch_app_config( + _app_config_from_conf( + next( + app[CONF_CONFIG] + for app in self._additional_app_configs + if app["name"] == source + ) + ) + ) ) elif source in self._available_apps: - await self._device.launch_app( - source, self._all_apps, log_api_exception=False - ) + await async_device_command(self._device.launch_app(source)) @override async def async_volume_up(self) -> None: """Increase volume of the device.""" - await self._device.vol_up(num=self._volume_step, log_api_exception=False) + await async_device_command(self._device.volume_up(steps=self._volume_step)) if self._attr_volume_level is not None: self._attr_volume_level = min( @@ -384,7 +432,7 @@ async def async_volume_up(self) -> None: @override async def async_volume_down(self) -> None: """Decrease volume of the device.""" - await self._device.vol_down(num=self._volume_step, log_api_exception=False) + await async_device_command(self._device.volume_down(steps=self._volume_step)) if self._attr_volume_level is not None: self._attr_volume_level = max( @@ -397,20 +445,20 @@ async def async_set_volume_level(self, volume: float) -> None: if self._attr_volume_level is not None: if volume > self._attr_volume_level: num = int(self._max_volume * (volume - self._attr_volume_level)) - await self._device.vol_up(num=num, log_api_exception=False) + await async_device_command(self._device.volume_up(steps=num)) self._attr_volume_level = volume elif volume < self._attr_volume_level: num = int(self._max_volume * (self._attr_volume_level - volume)) - await self._device.vol_down(num=num, log_api_exception=False) + await async_device_command(self._device.volume_down(steps=num)) self._attr_volume_level = volume @override async def async_media_play(self) -> None: """Play whatever media is currently active.""" - await self._device.play(log_api_exception=False) + await async_device_command(self._device.send_key(RemoteKey.PLAY)) @override async def async_media_pause(self) -> None: """Pause whatever media is currently active.""" - await self._device.pause(log_api_exception=False) + await async_device_command(self._device.send_key(RemoteKey.PAUSE)) diff --git a/homeassistant/components/vizio/remote.py b/homeassistant/components/vizio/remote.py index d5b86fc2c882f0..53e9d07d1cb5aa 100644 --- a/homeassistant/components/vizio/remote.py +++ b/homeassistant/components/vizio/remote.py @@ -20,10 +20,11 @@ from .const import DOMAIN from .coordinator import VizioConfigEntry, VizioDeviceCoordinator +from .helpers import async_device_command PARALLEL_UPDATES = 0 -# Maps native pyvizio key names to human-friendly aliases. +# Maps native vizaio key names to human-friendly aliases. # Keys are uppercase native names (e.g. "CC_TOGGLE"), values are lists of lowercase aliases. REMOTE_KEY_ALIASES: dict[str, list[str]] = { "CC_TOGGLE": ["closed_captions", "cc"], @@ -74,8 +75,8 @@ def __init__(self, config_entry: VizioConfigEntry) -> None: assert unique_id is not None self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) self._device = coordinator.device - valid_keys = set(self._device.get_remote_keys_list()) - # Map lowercased native keys to their original uppercase pyvizio names + valid_keys = set(self._device.available_keys) + # Map lowercased native keys to their original uppercase vizaio names self._command_map: dict[str, str] = {key.lower(): key for key in valid_keys} # Add aliases only for native keys this device actually supports for alias, target in _ALIAS_LOOKUP.items(): @@ -89,7 +90,7 @@ def is_on(self) -> bool: return self.coordinator.data.is_on def _resolve_command(self, command: str) -> str: - """Resolve an lowercased command string to a pyvizio key name.""" + """Resolve an lowercased command string to a vizaio key name.""" if resolved := self._command_map.get(command): return resolved raise ServiceValidationError( @@ -101,12 +102,12 @@ def _resolve_command(self, command: str) -> str: @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the device.""" - await self._device.pow_on(log_api_exception=False) + await async_device_command(self._device.power_on()) @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the device.""" - await self._device.pow_off(log_api_exception=False) + await async_device_command(self._device.power_off()) @override async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: @@ -117,6 +118,6 @@ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> Non for i in range(num_repeats): for cmd in resolved: - await self._device.remote(cmd, log_api_exception=False) + await async_device_command(self._device.send_key(cmd)) if i < num_repeats - 1: await asyncio.sleep(delay) diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index f305f4da410d87..f46dbd0637d049 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -41,6 +41,9 @@ } }, "exceptions": { + "command_error": { + "message": "Failed to send command to the device: {error}" + }, "unknown_command": { "message": "Unknown remote command `{command}`. Valid commands for this device are listed in the integration documentation." } diff --git a/homeassistant/components/wiim/diagnostics.py b/homeassistant/components/wiim/diagnostics.py new file mode 100644 index 00000000000000..23c2549afde553 --- /dev/null +++ b/homeassistant/components/wiim/diagnostics.py @@ -0,0 +1,39 @@ +"""Diagnostics support for WiiM.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .const import DATA_WIIM, WiimConfigEntry + +TO_REDACT = { + "configuration_url", + "ip_address", + "leader_udn", + "mac", + "mac_address", + "member_udns", + "name", + "serial", + "serial_number", + "title", + "udn", + "uuid", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: WiimConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + device = entry.runtime_data + wiim_data = hass.data[DATA_WIIM] + + return { + "device": async_redact_data(asdict(device.as_diagnostics()), TO_REDACT), + "multiroom": async_redact_data( + asdict(wiim_data.controller.get_group_snapshot(device.udn)), TO_REDACT + ), + } diff --git a/homeassistant/components/wiim/quality_scale.yaml b/homeassistant/components/wiim/quality_scale.yaml index 8edc6d9996495a..2babd6a5006f73 100644 --- a/homeassistant/components/wiim/quality_scale.yaml +++ b/homeassistant/components/wiim/quality_scale.yaml @@ -52,7 +52,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: todo diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 29dc0094b42ebc..add884165c3efe 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -4,10 +4,14 @@ from typing import cast from holidays import DateLike, HolidayBase +import voluptuous as vol +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.helpers import config_validation as cv, service +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from .const import ( @@ -16,6 +20,7 @@ CONF_OFFSET, CONF_PROVINCE, CONF_REMOVE_HOLIDAYS, + DOMAIN, LOGGER, PLATFORMS, ) @@ -28,6 +33,26 @@ type WorkdayConfigEntry = ConfigEntry[HolidayBase] +SERVICE_CHECK_DATE = "check_date" +CHECK_DATE = "check_date" + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Workday integration.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_CHECK_DATE, + entity_domain=BINARY_SENSOR_DOMAIN, + schema={vol.Required(CHECK_DATE): cv.date}, + func="check_date", + supports_response=SupportsResponse.ONLY, + ) + return True + async def async_setup_entry(hass: HomeAssistant, entry: WorkdayConfigEntry) -> bool: """Set up Workday from a config entry.""" diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 60ac0aa002595d..d69fac7170aa91 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -4,15 +4,10 @@ from typing import Final, override from holidays import HolidayBase -import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.core import HomeAssistant, SupportsResponse -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - async_get_current_platform, -) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WorkdayConfigEntry from .const import CONF_EXCLUDES, CONF_OFFSET, CONF_WORKDAYS @@ -33,15 +28,6 @@ async def async_setup_entry( workdays: list[str] = entry.options[CONF_WORKDAYS] obj_holidays = entry.runtime_data - platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_CHECK_DATE, - {vol.Required(CHECK_DATE): cv.date}, - "check_date", - None, - SupportsResponse.ONLY, - ) - async_add_entities( [ IsWorkdaySensor( diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 8afd72aa15f9bf..6b09a404b89ea4 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -227,6 +227,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ha_zha_data.gateway_proxy = ZHAGatewayProxy(hass, config_entry, zha_gateway) + # Ensure the gateway is torn down if setup fails after this point + config_entry.async_on_unload(ha_zha_data.gateway_proxy.shutdown) + manufacturer = zha_gateway.state.node_info.manufacturer model = zha_gateway.state.node_info.model @@ -285,11 +288,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ha_zha_data = get_zha_data(hass) ha_zha_data.config_entry = None - - if ha_zha_data.gateway_proxy is not None: - await ha_zha_data.gateway_proxy.shutdown() - ha_zha_data.gateway_proxy = None - + ha_zha_data.gateway_proxy = None ha_zha_data.update_coordinator = None # clean up any remaining entity metadata diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 441cc1cf847246..88624722b7eed6 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -4,6 +4,7 @@ from typing import override from zha.application.platforms.alarm_control_panel.const import ( + AlarmControlPanelEntityFeature as ZHAAlarmControlPanelEntityFeature, AlarmState as ZHAAlarmState, ) @@ -19,7 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import ZHAEntity +from .entity import ZHASupportedFeaturesEntity from .helpers import ( SIGNAL_ADD_ENTITIES, async_add_entities as zha_async_add_entities, @@ -64,23 +65,42 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -class ZHAAlarmControlPanel(ZHAEntity, AlarmControlPanelEntity): +class ZHAAlarmControlPanel(ZHASupportedFeaturesEntity, AlarmControlPanelEntity): """Entity for ZHA alarm control devices.""" _attr_translation_key: str = "alarm_control_panel" _attr_code_format = CodeFormat.TEXT - _attr_supported_features = ( - AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.TRIGGER - ) + + @staticmethod + @functools.cache + @override + def _convert_supported_features( + zha_features: int, + ) -> AlarmControlPanelEntityFeature: + """Convert ZHA alarm control panel features to HA ones.""" + zha_flags = ZHAAlarmControlPanelEntityFeature(zha_features) + features = AlarmControlPanelEntityFeature(0) + + if ZHAAlarmControlPanelEntityFeature.ARM_HOME in zha_flags: + features |= AlarmControlPanelEntityFeature.ARM_HOME + if ZHAAlarmControlPanelEntityFeature.ARM_AWAY in zha_flags: + features |= AlarmControlPanelEntityFeature.ARM_AWAY + if ZHAAlarmControlPanelEntityFeature.ARM_NIGHT in zha_flags: + features |= AlarmControlPanelEntityFeature.ARM_NIGHT + if ZHAAlarmControlPanelEntityFeature.TRIGGER in zha_flags: + features |= AlarmControlPanelEntityFeature.TRIGGER + if ZHAAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS in zha_flags: + features |= AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + if ZHAAlarmControlPanelEntityFeature.ARM_VACATION in zha_flags: + features |= AlarmControlPanelEntityFeature.ARM_VACATION + + return features @property @override def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" - return self.entity_data.entity.code_arm_required + return self._zha_state.code_arm_required @convert_zha_error_to_ha_error() @override @@ -121,4 +141,4 @@ async def async_alarm_trigger(self, code: str | None = None) -> None: @override def alarm_state(self) -> AlarmControlPanelState | None: """Return the state of the entity.""" - return ZHA_STATE_TO_ALARM_STATE_MAP.get(self.entity_data.entity.state["state"]) + return ZHA_STATE_TO_ALARM_STATE_MAP.get(self._zha_state.alarm_state) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index f6d7ca881c1d78..b9761c7e37d084 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -47,13 +47,13 @@ class BinarySensor(ZHAEntity, BinarySensorEntity): def __init__(self, entity_data: EntityData) -> None: """Initialize the ZHA binary sensor.""" super().__init__(entity_data) - if self.entity_data.entity.info_object.device_class is not None: + if self._zha_state.device_class is not None: self._attr_device_class = BinarySensorDeviceClass( - self.entity_data.entity.info_object.device_class + self._zha_state.device_class ) @property @override def is_on(self) -> bool: """Return True if the switch is on based on the state machine.""" - return self.entity_data.entity.is_on + return self._zha_state.is_on diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 8a3c177c26febd..91b4a7293c9d5d 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -48,10 +48,8 @@ class ZHAButton(ZHAEntity, ButtonEntity): def __init__(self, entity_data: EntityData) -> None: """Initialize the ZHA binary sensor.""" super().__init__(entity_data) - if self.entity_data.entity.info_object.device_class is not None: - self._attr_device_class = ButtonDeviceClass( - self.entity_data.entity.info_object.device_class - ) + if self._zha_state.device_class is not None: + self._attr_device_class = ButtonDeviceClass(self._zha_state.device_class) @convert_zha_error_to_ha_error() @override diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 185ee3be30acfd..c7ffb8e9917d6f 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -8,6 +8,7 @@ import functools from typing import Any, override +from zha.application.platforms.climate import ThermostatState from zha.application.platforms.climate.const import ( ClimateEntityFeature as ZHAClimateEntityFeature, HVACAction as ZHAHVACAction, @@ -26,14 +27,13 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PRECISION_TENTHS, Platform, UnitOfTemperature -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import ZHAEntity +from .entity import ZHASupportedFeaturesEntity from .helpers import ( SIGNAL_ADD_ENTITIES, - EntityData, async_add_entities as zha_async_add_entities, convert_zha_error_to_ha_error, exclude_none_values, @@ -80,30 +80,21 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -class Thermostat(ZHAEntity, ClimateEntity): +class Thermostat(ZHASupportedFeaturesEntity, ClimateEntity): """Representation of a ZHA Thermostat device.""" _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key: str = "thermostat" - def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: - """Initialize the ZHA thermostat entity.""" - super().__init__(entity_data, **kwargs) - self._attr_hvac_modes = [ - ZHA_TO_HA_HVAC_MODE[mode] for mode in self.entity_data.entity.hvac_modes - ] - self._attr_hvac_mode = ZHA_TO_HA_HVAC_MODE.get( - self.entity_data.entity.hvac_mode - ) - self._attr_hvac_action = ZHA_TO_HA_HVAC_ACTION.get( - self.entity_data.entity.hvac_action - ) - - features: ClimateEntityFeature = ClimateEntityFeature(0) - zha_features: ZHAClimateEntityFeature = ( - self.entity_data.entity.supported_features - ) + @staticmethod + @functools.cache + @override + def _convert_supported_features( + zha_features: ZHAClimateEntityFeature, + ) -> ClimateEntityFeature: + """Convert ZHA climate features to HA climate features.""" + features = ClimateEntityFeature(0) if ZHAClimateEntityFeature.TARGET_TEMPERATURE in zha_features: features |= ClimateEntityFeature.TARGET_TEMPERATURE @@ -122,24 +113,39 @@ def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: if ZHAClimateEntityFeature.TURN_ON in zha_features: features |= ClimateEntityFeature.TURN_ON - self._attr_supported_features = features + return features + + @override + def _update_capability_attrs(self) -> None: + """Re-derive capability attributes from the cached state.""" + super()._update_capability_attrs() + + state = self._zha_state + self._attr_hvac_modes = [ZHA_TO_HA_HVAC_MODE[mode] for mode in state.hvac_modes] + self._attr_fan_modes = state.fan_modes + self._attr_preset_modes = state.preset_modes + self._attr_min_temp = state.min_temp + self._attr_max_temp = state.max_temp @property @override def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes.""" - state = self.entity_data.entity.state + state = self._zha_state + + if not isinstance(state, ThermostatState): + return None return exclude_none_values( { - "occupancy": state.get("occupancy"), - "occupied_cooling_setpoint": state.get("occupied_cooling_setpoint"), - "occupied_heating_setpoint": state.get("occupied_heating_setpoint"), - "pi_cooling_demand": state.get("pi_cooling_demand"), - "pi_heating_demand": state.get("pi_heating_demand"), - "system_mode": state.get("system_mode"), - "unoccupied_cooling_setpoint": state.get("unoccupied_cooling_setpoint"), - "unoccupied_heating_setpoint": state.get("unoccupied_heating_setpoint"), + "occupancy": state.occupancy, + "occupied_cooling_setpoint": state.occupied_cooling_setpoint, + "occupied_heating_setpoint": state.occupied_heating_setpoint, + "pi_cooling_demand": state.pi_cooling_demand, + "pi_heating_demand": state.pi_heating_demand, + "system_mode": state.sys_mode, + "unoccupied_cooling_setpoint": state.unoccupied_cooling_setpoint, + "unoccupied_heating_setpoint": state.unoccupied_heating_setpoint, } ) @@ -147,73 +153,49 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: @override def current_temperature(self) -> float | None: """Return the current temperature.""" - return self.entity_data.entity.current_temperature + return self._zha_state.current_temperature @property @override def fan_mode(self) -> str | None: """Return current FAN mode.""" - return self.entity_data.entity.fan_mode - - @property - @override - def fan_modes(self) -> list[str] | None: - """Return supported FAN modes.""" - return self.entity_data.entity.fan_modes + return self._zha_state.fan_mode @property @override def preset_mode(self) -> str: """Return current preset mode.""" - return self.entity_data.entity.preset_mode - - @property - @override - def preset_modes(self) -> list[str] | None: - """Return supported preset modes.""" - return self.entity_data.entity.preset_modes + return self._zha_state.preset_mode @property @override def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return self.entity_data.entity.target_temperature + return self._zha_state.target_temperature @property @override def target_temperature_high(self) -> float | None: """Return the upper bound temperature we try to reach.""" - return self.entity_data.entity.target_temperature_high + return self._zha_state.target_temperature_high @property @override def target_temperature_low(self) -> float | None: """Return the lower bound temperature we try to reach.""" - return self.entity_data.entity.target_temperature_low + return self._zha_state.target_temperature_low @property @override - def max_temp(self) -> float: - """Return the maximum temperature.""" - return self.entity_data.entity.max_temp + def hvac_mode(self) -> HVACMode | None: + """Return HVAC operation mode.""" + return ZHA_TO_HA_HVAC_MODE.get(self._zha_state.hvac_mode) @property @override - def min_temp(self) -> float: - """Return the minimum temperature.""" - return self.entity_data.entity.min_temp - - @callback - @override - def _handle_entity_events(self, event: Any) -> None: - """Entity state changed.""" - self._attr_hvac_mode = self._attr_hvac_mode = ZHA_TO_HA_HVAC_MODE.get( - self.entity_data.entity.hvac_mode - ) - self._attr_hvac_action = ZHA_TO_HA_HVAC_ACTION.get( - self.entity_data.entity.hvac_action - ) - super()._handle_entity_events(event) + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return ZHA_TO_HA_HVAC_ACTION.get(self._zha_state.hvac_action) @convert_zha_error_to_ha_error() @override diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 58059a3d98e613..e3bd99d2223f70 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -22,10 +22,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import ZHAEntity +from .entity import ZHASupportedFeaturesEntity from .helpers import ( SIGNAL_ADD_ENTITIES, - EntityData, async_add_entities as zha_async_add_entities, convert_zha_error_to_ha_error, get_zha_data, @@ -53,19 +52,12 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -class ZhaCover(ZHAEntity, CoverEntity): +class ZhaCover(ZHASupportedFeaturesEntity, CoverEntity): """Representation of a ZHA cover.""" - def __init__(self, entity_data: EntityData) -> None: - """Initialize the ZHA cover.""" - super().__init__(entity_data) - - if self.entity_data.entity.info_object.device_class is not None: - self._attr_device_class = CoverDeviceClass( - self.entity_data.entity.info_object.device_class - ) - @staticmethod + @functools.cache + @override def _convert_supported_features( zha_features: ZHACoverEntityFeature, ) -> CoverEntityFeature: @@ -91,42 +83,45 @@ def _convert_supported_features( return features - @property @override - def supported_features(self) -> CoverEntityFeature: - """Return the supported features.""" - zha_features: ZHACoverEntityFeature = self.entity_data.entity.supported_features - return self._convert_supported_features(zha_features) + def _update_capability_attrs(self) -> None: + """Re-derive capability attributes from the cached state.""" + super()._update_capability_attrs() + + device_class = self._zha_state.device_class + self._attr_device_class = ( + CoverDeviceClass(device_class) if device_class is not None else None + ) @property @override def is_closed(self) -> bool | None: """Return True if the cover is closed.""" - return self.entity_data.entity.is_closed + return self._zha_state.is_closed @property @override def is_opening(self) -> bool: """Return if the cover is opening or not.""" - return self.entity_data.entity.is_opening + return self._zha_state.is_opening @property @override def is_closing(self) -> bool: """Return if the cover is closing or not.""" - return self.entity_data.entity.is_closing + return self._zha_state.is_closing @property @override def current_cover_position(self) -> int | None: """Return the current position of ZHA cover.""" - return self.entity_data.entity.current_cover_position + return self._zha_state.current_position @property @override def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" - return self.entity_data.entity.current_cover_tilt_position + return self._zha_state.current_tilt_position @convert_zha_error_to_ha_error() @override diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 02cd5dab90096e..a648916605145b 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -51,7 +51,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZHAEntity): @override def is_connected(self) -> bool: """Return true if the device is connected to the network.""" - return self.entity_data.entity.is_connected + return self._zha_state.connected @property @override @@ -60,7 +60,7 @@ def battery_level(self) -> int | None: Percentage from 0-100. """ - return self.entity_data.entity.battery_level + return self._zha_state.battery_level @property # type: ignore[misc] @override diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index c3624a5f4d9436..2dbe516f925da6 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -2,11 +2,14 @@ import asyncio from collections.abc import Callable +import dataclasses +from enum import IntFlag from functools import partial import logging from typing import Any, override from propcache.api import cached_property +from zha.application.platforms import EntityStateChangedEvent from zha.mixins import LogMixin from homeassistant.const import ( @@ -47,12 +50,13 @@ def __init__(self, entity_data: EntityData, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.entity_data: EntityData = entity_data self._unsubs: list[Callable[[], None]] = [] + self._zha_state = self.entity_data.entity.state if self.entity_data.entity.icon is not None: # Only custom quirks will realistically set an icon self._attr_icon = self.entity_data.entity.icon - meta = self.entity_data.entity.info_object + meta = self._zha_state self._attr_unique_id = meta.unique_id if self.entity_data.is_group_entity: @@ -60,7 +64,7 @@ def __init__(self, entity_data: EntityData, *args, **kwargs) -> None: assert group_proxy is not None platform = self.entity_data.entity.PLATFORM unique_ids = [ - entity.info_object.unique_id + entity.identifiers.unique_id for member in group_proxy.group.members for entity in member.associated_entities if platform == entity.PLATFORM @@ -80,6 +84,8 @@ def __init__(self, entity_data: EntityData, *args, **kwargs) -> None: if meta.translation_placeholders is not None: self._attr_translation_placeholders = meta.translation_placeholders + self._update_capability_attrs() + @cached_property @override def name(self) -> str | UndefinedType | None: @@ -91,7 +97,7 @@ def name(self) -> str | UndefinedType | None: If a device class is set but no translation key, the device class name is used. """ - meta = self.entity_data.entity.info_object + meta = self._zha_state if meta.primary: self._attr_name = None return super().name @@ -120,7 +126,7 @@ def name(self) -> str | UndefinedType | None: @override def available(self) -> bool: """Return entity availability.""" - return self.entity_data.entity.available + return self._zha_state.available @property @override @@ -144,19 +150,21 @@ def device_info(self) -> DeviceInfo: ) return device_info + def _update_capability_attrs(self) -> None: + """Re-derive capability `_attr_*` attributes from the cached state.""" + @callback - def _handle_entity_events(self, event: Any) -> None: - """Entity state changed.""" + def _handle_zha_entity_state_changed(self, event: EntityStateChangedEvent) -> None: + """Handle a state change reported by the ZHA library entity.""" self.debug("Handling event from entity: %s", event) + self._zha_state = dataclasses.replace(self._zha_state, **event.state_diff) + self._update_capability_attrs() self.async_write_ha_state() @override async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" self.remove_future = self.hass.loop.create_future() - self._unsubs.append( - self.entity_data.entity.on_all_events(self._handle_entity_events) - ) remove_signal = ( f"{SIGNAL_REMOVE_ENTITIES}_group_{self.entity_data.group_proxy.group.group_id}" if self.entity_data.is_group_entity @@ -187,10 +195,16 @@ async def async_added_to_hass(self) -> None: self.remove_future, ) - if (state := await self.async_get_last_state()) is None: - return + if (state := await self.async_get_last_state()) is not None: + self.restore_external_state_attributes(state) - self.restore_external_state_attributes(state) + # The subscription synchronously delivers the full current state as its + # first event, establishing a baseline coherent with subsequent diffs. + self._unsubs.append( + self.entity_data.entity.subscribe_state( + self._handle_zha_entity_state_changed + ) + ) @callback def restore_external_state_attributes(self, state: State) -> None: @@ -221,8 +235,25 @@ def log(self, level: int, msg: str, *args, **kwargs): """Log a message.""" if not _LOGGER.isEnabledFor(level): # Avoid building the prefixed message and args tuple for disabled - # levels; this runs for every entity event via _handle_entity_events. + # levels; this runs for every entity event via + # _handle_zha_entity_state_changed. return msg = f"%s: {msg}" args = (self.entity_id, *args) _LOGGER.log(level, msg, *args, **kwargs) + + +class ZHASupportedFeaturesEntity(ZHAEntity): + """ZHA entity whose state carries a `supported_features` flag to translate.""" + + @override + def _update_capability_attrs(self) -> None: + """Re-derive capability `_attr_*` attributes from the cached state.""" + self._attr_supported_features = self._convert_supported_features( + self._zha_state.supported_features + ) + + @staticmethod + def _convert_supported_features(zha_features: IntFlag) -> IntFlag: + """Translate ZHA feature flags into their HA equivalents.""" + raise NotImplementedError diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 4356d158fb30c1..de339faeeb772b 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -12,10 +12,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import ZHAEntity +from .entity import ZHASupportedFeaturesEntity from .helpers import ( SIGNAL_ADD_ENTITIES, - EntityData, async_add_entities as zha_async_add_entities, convert_zha_error_to_ha_error, get_zha_data, @@ -41,16 +40,19 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -class ZhaFan(FanEntity, ZHAEntity): +class ZhaFan(FanEntity, ZHASupportedFeaturesEntity): """Representation of a ZHA fan.""" _attr_translation_key: str = "fan" - def __init__(self, entity_data: EntityData) -> None: - """Initialize the ZHA fan.""" - super().__init__(entity_data) + @staticmethod + @functools.cache + @override + def _convert_supported_features( + zha_features: ZHAFanEntityFeature, + ) -> FanEntityFeature: + """Convert ZHA fan features to HA fan features.""" features = FanEntityFeature(0) - zha_features: ZHAFanEntityFeature = self.entity_data.entity.supported_features if ZHAFanEntityFeature.DIRECTION in zha_features: features |= FanEntityFeature.DIRECTION @@ -65,35 +67,35 @@ def __init__(self, entity_data: EntityData) -> None: if ZHAFanEntityFeature.TURN_OFF in zha_features: features |= FanEntityFeature.TURN_OFF - self._attr_supported_features = features + return features @property @override def preset_mode(self) -> str | None: """Return the current preset mode.""" - return self.entity_data.entity.preset_mode + return self._zha_state.preset_mode @property @override def preset_modes(self) -> list[str]: """Return the available preset modes.""" - return self.entity_data.entity.preset_modes + return self._zha_state.preset_modes @property def default_on_percentage(self) -> int: """Return the default on percentage.""" - return self.entity_data.entity.default_on_percentage + return self._zha_state.default_on_percentage @property def speed_range(self) -> tuple[int, int]: """Return the range of speeds the fan supports. Off is not included.""" - return self.entity_data.entity.speed_range + return self._zha_state.speed_range @property @override def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - return self.entity_data.entity.speed_count + return self._zha_state.speed_count @convert_zha_error_to_ha_error() @override @@ -134,4 +136,4 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: @override def percentage(self) -> int | None: """Return the current speed percentage.""" - return self.entity_data.entity.percentage + return self._zha_state.percentage diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index dc23eeeb1bcb74..2c21f66ecca930 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -11,7 +11,6 @@ import logging import queue import re -import time from types import MappingProxyType from typing import TYPE_CHECKING, Any, NamedTuple, cast, override from zoneinfo import ZoneInfo @@ -357,26 +356,25 @@ def device_id(self, device_id: str) -> None: @property def device_info(self) -> dict[str, Any]: """Return a device description for device.""" - ieee = str(self.device.ieee) - time_struct = time.localtime(self.device.last_seen) - update_time = time.strftime("%Y-%m-%dT%H:%M:%S", time_struct) + info = self.device.device_info + ieee = str(info.ieee) return { ATTR_IEEE: ieee, - ATTR_NWK: self.device.nwk, - ATTR_MANUFACTURER: self.device.manufacturer, - ATTR_MODEL: self.device.model, - ATTR_NAME: self.device.name or ieee, - ATTR_QUIRK_APPLIED: self.device.quirk_applied, - ATTR_QUIRK_CLASS: self.device.quirk_class, - ATTR_EXPOSES_FEATURES: self.device.exposes_features, - ATTR_MANUFACTURER_CODE: self.device.manufacturer_code, - ATTR_POWER_SOURCE: self.device.power_source, - ATTR_LQI: self.device.lqi, - ATTR_RSSI: self.device.rssi, - ATTR_LAST_SEEN: update_time, - ATTR_AVAILABLE: self.device.available, - ATTR_DEVICE_TYPE: self.device.device_type, - ATTR_SIGNATURE: self.device.zigbee_signature, + ATTR_NWK: info.nwk, + ATTR_MANUFACTURER: info.manufacturer, + ATTR_MODEL: info.model, + ATTR_NAME: info.name or ieee, + ATTR_QUIRK_APPLIED: info.quirk_applied, + ATTR_QUIRK_CLASS: info.quirk_class, + ATTR_EXPOSES_FEATURES: info.exposes_features, + ATTR_MANUFACTURER_CODE: info.manufacturer_code, + ATTR_POWER_SOURCE: info.power_source, + ATTR_LQI: info.lqi, + ATTR_RSSI: info.rssi, + ATTR_LAST_SEEN: info.last_seen, + ATTR_AVAILABLE: info.available, + ATTR_DEVICE_TYPE: info.device_type, + ATTR_SIGNATURE: info.signature, } @property diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 43d4f7c421f3c0..7fd4364ad03399 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -29,10 +29,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from .entity import ZHAEntity +from .entity import ZHASupportedFeaturesEntity from .helpers import ( SIGNAL_ADD_ENTITIES, - EntityData, async_add_entities as zha_async_add_entities, convert_zha_error_to_ha_error, get_zha_data, @@ -73,30 +72,17 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -class Light(LightEntity, ZHAEntity): +class Light(LightEntity, ZHASupportedFeaturesEntity): """Representation of a ZHA or ZLL light.""" - def __init__(self, entity_data: EntityData) -> None: - """Initialize the ZHA light.""" - super().__init__(entity_data) - color_modes: set[ColorMode] = set() - has_brightness = False - for color_mode in self.entity_data.entity.supported_color_modes: - if color_mode == ZhaColorMode.BRIGHTNESS: - has_brightness = True - if color_mode not in (ZhaColorMode.BRIGHTNESS, ZhaColorMode.ONOFF): - color_modes.add(ZHA_TO_HA_COLOR_MODE[color_mode]) - if color_modes: - self._attr_supported_color_modes = color_modes - elif has_brightness: - color_modes.add(ColorMode.BRIGHTNESS) - self._attr_supported_color_modes = color_modes - else: - color_modes.add(ColorMode.ONOFF) - self._attr_supported_color_modes = color_modes - + @staticmethod + @functools.cache + @override + def _convert_supported_features( + zha_features: ZhaLightEntityFeature, + ) -> LightEntityFeature: + """Convert ZHA light features to HA light features.""" features = LightEntityFeature(0) - zha_features: ZhaLightEntityFeature = self.entity_data.entity.supported_features if ZhaLightEntityFeature.EFFECT in zha_features: features |= LightEntityFeature.EFFECT @@ -105,51 +91,60 @@ def __init__(self, entity_data: EntityData) -> None: if ZhaLightEntityFeature.TRANSITION in zha_features: features |= LightEntityFeature.TRANSITION - self._attr_supported_features = features + return features + + @override + def _update_capability_attrs(self) -> None: + """Re-derive capability attributes from the cached state.""" + super()._update_capability_attrs() + state = self._zha_state + + color_modes: set[ColorMode] = set() + has_brightness = False + for color_mode in state.supported_color_modes: + if color_mode == ZhaColorMode.BRIGHTNESS: + has_brightness = True + if color_mode not in (ZhaColorMode.BRIGHTNESS, ZhaColorMode.ONOFF): + color_modes.add(ZHA_TO_HA_COLOR_MODE[color_mode]) + if not color_modes: + color_modes.add(ColorMode.BRIGHTNESS if has_brightness else ColorMode.ONOFF) + self._attr_supported_color_modes = color_modes + + self._attr_max_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( + state.min_mireds + ) + self._attr_min_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( + state.max_mireds + ) + self._attr_effect_list = state.effect_list @property @override def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes.""" - state = self.entity_data.entity.state + state = self._zha_state return { - "off_with_transition": state.get("off_with_transition"), - "off_brightness": state.get("off_brightness"), + "off_with_transition": state.off_with_transition, + "off_brightness": state.off_brightness, } @property @override def is_on(self) -> bool: """Return true if entity is on.""" - return self.entity_data.entity.is_on + return self._zha_state.on @property @override def brightness(self) -> int: """Return the brightness of this light.""" - return self.entity_data.entity.brightness - - @property - @override - def max_color_temp_kelvin(self) -> int: - """Return the coldest color_temp_kelvin that this light supports.""" - return color_util.color_temperature_mired_to_kelvin( - self.entity_data.entity.min_mireds - ) - - @property - @override - def min_color_temp_kelvin(self) -> int: - """Return the warmest color_temp_kelvin that this light supports.""" - return color_util.color_temperature_mired_to_kelvin( - self.entity_data.entity.max_mireds - ) + return self._zha_state.brightness @property @override def xy_color(self) -> tuple[float, float] | None: """Return the xy color value [float, float].""" - return self.entity_data.entity.xy_color + return self._zha_state.xy_color @property @override @@ -157,7 +152,7 @@ def color_temp_kelvin(self) -> int | None: """Return the color temperature value in Kelvin.""" return ( color_util.color_temperature_mired_to_kelvin(mireds) - if (mireds := self.entity_data.entity.color_temp) + if (mireds := self._zha_state.color_temp) else None ) @@ -165,21 +160,15 @@ def color_temp_kelvin(self) -> int | None: @override def color_mode(self) -> ColorMode: """Return the color mode.""" - if self.entity_data.entity.color_mode is None: + if self._zha_state.color_mode is None: return ColorMode.UNKNOWN - return ZHA_TO_HA_COLOR_MODE[self.entity_data.entity.color_mode] - - @property - @override - def effect_list(self) -> list[str] | None: - """Return the list of supported effects.""" - return self.entity_data.entity.effect_list + return ZHA_TO_HA_COLOR_MODE[self._zha_state.color_mode] @property @override def effect(self) -> str | None: """Return the current effect.""" - return self.entity_data.entity.effect + return self._zha_state.effect @convert_zha_error_to_ha_error() @override diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 166caeeefd07c9..66294af2b7b3c2 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -93,7 +93,7 @@ class ZhaDoorLock(ZHAEntity, LockEntity): @override def is_locked(self) -> bool: """Return true if entity is locked.""" - return self.entity_data.entity.is_locked + return self._zha_state.is_locked @convert_zha_error_to_ha_error() @override diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 7112914208f5bd..350a81eeb0d6ca 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==2.0.1", "zha-quirks==2.1.1"], + "requirements": ["zha==2.1.0", "zha-quirks==2.2.0"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 7f350e4759a908..c0d8f1917c4dc0 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -51,37 +51,22 @@ def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: entity = entity_data.entity if entity.device_class is not None: self._attr_device_class = NumberDeviceClass(entity.device_class) - self._attr_mode = NumberMode(entity.mode) - @property - @override - def native_value(self) -> float | None: - """Return the current value.""" - return self.entity_data.entity.native_value - - @property - @override - def native_min_value(self) -> float: - """Return the minimum value.""" - return self.entity_data.entity.native_min_value - - @property @override - def native_max_value(self) -> float: - """Return the maximum value.""" - return self.entity_data.entity.native_max_value + def _update_capability_attrs(self) -> None: + """Re-derive capability attributes from the cached state.""" + state = self._zha_state + self._attr_mode = NumberMode(state.mode) + self._attr_native_min_value = state.native_min_value + self._attr_native_max_value = state.native_max_value + self._attr_native_step = state.native_step + self._attr_native_unit_of_measurement = state.native_unit_of_measurement @property @override - def native_step(self) -> float | None: - """Return the value step.""" - return self.entity_data.entity.native_step - - @property - @override - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self.entity_data.entity.native_unit_of_measurement + def native_value(self) -> float | None: + """Return the current value.""" + return self._zha_state.native_value @convert_zha_error_to_ha_error() @override diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 57279894221330..3a1aae57639075 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -2,7 +2,7 @@ import functools import logging -from typing import Any, override +from typing import override from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry @@ -14,7 +14,6 @@ from .entity import ZHAEntity from .helpers import ( SIGNAL_ADD_ENTITIES, - EntityData, async_add_entities as zha_async_add_entities, convert_zha_error_to_ha_error, get_zha_data, @@ -48,16 +47,16 @@ async def async_setup_entry( class ZHAEnumSelectEntity(ZHAEntity, SelectEntity): """Representation of a ZHA select entity.""" - def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: - """Initialize the ZHA select entity.""" - super().__init__(entity_data, **kwargs) - self._attr_options = self.entity_data.entity.info_object.options + @override + def _update_capability_attrs(self) -> None: + """Re-derive capability attributes from the cached state.""" + self._attr_options = self._zha_state.options @property @override def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - return self.entity_data.entity.current_option + return self._zha_state.current_option @convert_zha_error_to_ha_error() @override diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index f957ecbbcd4347..d64a607f57ef65 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -103,14 +103,14 @@ def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: super().__init__(entity_data, **kwargs) entity = self.entity_data.entity - if entity.device_class is not None: - self._attr_device_class = SensorDeviceClass(entity.device_class) + if self._zha_state.device_class is not None: + self._attr_device_class = SensorDeviceClass(self._zha_state.device_class) - if entity.state_class is not None: - self._attr_state_class = SensorStateClass(entity.state_class) + if self._zha_state.state_class is not None: + self._attr_state_class = SensorStateClass(self._zha_state.state_class) - if hasattr(entity.info_object, "unit") and entity.info_object.unit is not None: - self._attr_native_unit_of_measurement = entity.info_object.unit + if hasattr(self._zha_state, "unit") and self._zha_state.unit is not None: + self._attr_native_unit_of_measurement = self._zha_state.unit if ( hasattr(entity, "entity_description") @@ -136,35 +136,34 @@ def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: entity_description.device_class.value ) - if entity.info_object.suggested_display_precision is not None: + if self._zha_state.suggested_display_precision is not None: self._attr_suggested_display_precision = ( - entity.info_object.suggested_display_precision + self._zha_state.suggested_display_precision ) + if hasattr(self._zha_state, "options"): + self._attr_options = self._zha_state.options + @property @override def native_value(self) -> StateType: """Return the state of the entity.""" - return self.entity_data.entity.native_value + return self._zha_state.native_value @property @override def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes.""" - entity = self.entity_data.entity - if entity.extra_state_attribute_names is None: + if not self._zha_state.extra_state_attribute_names: return None - if not entity.extra_state_attribute_names <= _EXTRA_STATE_ATTRIBUTES: + extra_state_attributes = self._zha_state.extra_state_attributes + + if not extra_state_attributes.keys() <= _EXTRA_STATE_ATTRIBUTES: _LOGGER.warning( "Unexpected extra state attributes found for sensor %s: %s", - entity, - entity.extra_state_attribute_names - _EXTRA_STATE_ATTRIBUTES, + self.entity_data.entity, + extra_state_attributes.keys() - _EXTRA_STATE_ATTRIBUTES, ) - return exclude_none_values( - { - name: entity.state.get(name) - for name in entity.extra_state_attribute_names - } - ) + return exclude_none_values(extra_state_attributes) diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index b2ac0469af89fd..ac7f525e18ec1d 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -21,10 +21,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import ZHAEntity +from .entity import ZHASupportedFeaturesEntity from .helpers import ( SIGNAL_ADD_ENTITIES, - EntityData, async_add_entities as zha_async_add_entities, convert_zha_error_to_ha_error, get_zha_data, @@ -50,7 +49,7 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -class ZHASiren(ZHAEntity, SirenEntity): +class ZHASiren(ZHASupportedFeaturesEntity, SirenEntity): """Representation of a ZHA siren.""" _attr_available_tones: list[int | str] | dict[int, str] | None = { @@ -62,12 +61,14 @@ class ZHASiren(ZHAEntity, SirenEntity): WarningMode.Emergency_Panic: "Emergency Panic", } - def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: - """Initialize the ZHA siren.""" - super().__init__(entity_data, **kwargs) - - features: SirenEntityFeature = SirenEntityFeature(0) - zha_features: ZHASirenEntityFeature = self.entity_data.entity.supported_features + @staticmethod + @functools.cache + @override + def _convert_supported_features( + zha_features: ZHASirenEntityFeature, + ) -> SirenEntityFeature: + """Convert ZHA siren features to HA siren features.""" + features = SirenEntityFeature(0) if ZHASirenEntityFeature.TURN_ON in zha_features: features |= SirenEntityFeature.TURN_ON @@ -80,13 +81,13 @@ def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: if ZHASirenEntityFeature.DURATION in zha_features: features |= SirenEntityFeature.DURATION - self._attr_supported_features = features + return features @property @override def is_on(self) -> bool: """Return True if entity is on.""" - return self.entity_data.entity.is_on + return self._zha_state.is_on @convert_zha_error_to_ha_error() @override diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 68cbf0e51c7d43..50b5cc71802fee 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -339,6 +339,9 @@ "test": { "name": "Test" }, + "tilt": { + "name": "Tilt" + }, "valve_alarm": { "name": "Valve alarm" }, @@ -425,6 +428,21 @@ "reset_summation_delivered_right": { "name": "Reset right summation delivered" }, + "reset_total_energy": { + "name": "Reset total energy" + }, + "reset_total_energy_bottom": { + "name": "Reset bottom total energy" + }, + "reset_total_energy_left": { + "name": "Reset left total energy" + }, + "reset_total_energy_right": { + "name": "Reset right total energy" + }, + "reset_total_energy_top": { + "name": "Reset top total energy" + }, "restart_device": { "name": "Restart device" }, @@ -593,6 +611,36 @@ "compensation_speed": { "name": "Compensation speed" }, + "countdown_to_turn_off": { + "name": "Countdown to turn off" + }, + "countdown_to_turn_off_bottom": { + "name": "Countdown to turn off bottom" + }, + "countdown_to_turn_off_left": { + "name": "Countdown to turn off left" + }, + "countdown_to_turn_off_right": { + "name": "Countdown to turn off right" + }, + "countdown_to_turn_off_top": { + "name": "Countdown to turn off top" + }, + "countdown_to_turn_on": { + "name": "Countdown to turn on" + }, + "countdown_to_turn_on_bottom": { + "name": "Countdown to turn on bottom" + }, + "countdown_to_turn_on_left": { + "name": "Countdown to turn on left" + }, + "countdown_to_turn_on_right": { + "name": "Countdown to turn on right" + }, + "countdown_to_turn_on_top": { + "name": "Countdown to turn on top" + }, "deadzone_temperature": { "name": "Deadzone temperature" }, @@ -833,6 +881,27 @@ "minimum_on_level": { "name": "Minimum on level" }, + "mmwave_depth_maximum_far": { + "name": "mmWave depth maximum (far)" + }, + "mmwave_depth_minimum_near": { + "name": "mmWave depth minimum (near)" + }, + "mmwave_height_maximum_ceiling": { + "name": "mmWave height maximum (ceiling)" + }, + "mmwave_height_minimum_floor": { + "name": "mmWave height minimum (floor)" + }, + "mmwave_hold_time": { + "name": "mmWave hold time" + }, + "mmwave_width_maximum_right": { + "name": "mmWave width maximum (right)" + }, + "mmwave_width_minimum_left": { + "name": "mmWave width minimum (left)" + }, "motion_detection_sensitivity": { "name": "Motion detection sensitivity" }, @@ -911,6 +980,12 @@ "portion_weight": { "name": "Portion weight" }, + "power_drop_threshold": { + "name": "Power drop threshold" + }, + "power_rise_threshold": { + "name": "Power rise threshold" + }, "presence_detection_timeout": { "name": "Presence detection timeout" }, @@ -953,6 +1028,9 @@ "ramp_rate_on_to_off_remote": { "name": "Remote ramp rate on to off" }, + "red_led_brightness": { + "name": "Red LED brightness" + }, "regulation_setpoint_offset": { "name": "Regulation setpoint offset" }, @@ -1121,6 +1199,9 @@ "valve_opening_degree": { "name": "Valve opening degree" }, + "valve_position": { + "name": "Valve position" + }, "valve_state_auto_shutdown": { "name": "Valve state auto-shutdown" }, @@ -1294,12 +1375,24 @@ "led_scaling_mode": { "name": "LED scaling mode" }, + "light_on_presence_behavior": { + "name": "Light on presence behavior" + }, "liquid_state": { "name": "Liquid state" }, "local_temperature_source": { "name": "Local temperature source" }, + "mmwave_detect_sensitivity": { + "name": "mmWave sensitivity" + }, + "mmwave_detect_trigger": { + "name": "mmWave target speed" + }, + "mmwave_room_size_preset": { + "name": "mmWave room size preset" + }, "mode": { "name": "Mode" }, @@ -1399,6 +1492,9 @@ "switch_actions": { "name": "Switch actions" }, + "switch_actions_id": { + "name": "{id} switch actions" + }, "switch_indication": { "name": "Switch indication" }, @@ -1408,6 +1504,9 @@ "switch_type": { "name": "Switch type" }, + "switch_type_id": { + "name": "{id} switch type" + }, "switch_type_l1": { "name": "Switch type L1" }, @@ -2056,9 +2155,18 @@ "linkage_alarm": { "name": "Linkage alarm" }, + "local_control_id": { + "name": "{id} local control" + }, "local_protection": { "name": "Local protection" }, + "manual_mode": { + "name": "Manual mode" + }, + "metering_only_mode": { + "name": "Metering only mode" + }, "mounting_mode": { "name": "Mounting mode" }, @@ -2089,6 +2197,9 @@ "relay_click_in_on_off_mode": { "name": "Disable relay click in on off mode" }, + "remote_protection": { + "name": "Remote protection" + }, "scale_protection": { "name": "Scale protection" }, diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 9a798795e37bdb..efad8db488b68d 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -48,7 +48,7 @@ class Switch(ZHAEntity, SwitchEntity): @override def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - return self.entity_data.entity.is_on + return self._zha_state.is_on @convert_zha_error_to_ha_error() @override diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index c3bc14433db3cf..0dff0993623f32 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -4,6 +4,9 @@ import logging from typing import Any, override +from zha.application.platforms.update import ( + UpdateEntityFeature as ZHAUpdateEntityFeature, +) from zha.exceptions import ZHAException from zigpy.application import ControllerApplication @@ -23,7 +26,7 @@ DataUpdateCoordinator, ) -from .entity import ZHAEntity +from .entity import ZHASupportedFeaturesEntity from .helpers import ( SIGNAL_ADD_ENTITIES, EntityData, @@ -99,19 +102,37 @@ async def async_update_data(self) -> None: class ZHAFirmwareUpdateEntity( - ZHAEntity, CoordinatorEntity[ZHAFirmwareUpdateCoordinator], UpdateEntity + ZHASupportedFeaturesEntity, + CoordinatorEntity[ZHAFirmwareUpdateCoordinator], + UpdateEntity, ): """Representation of a ZHA firmware update entity.""" _attr_device_class = UpdateDeviceClass.FIRMWARE - _attr_supported_features = ( - UpdateEntityFeature.INSTALL - | UpdateEntityFeature.PROGRESS - | UpdateEntityFeature.SPECIFIC_VERSION - | UpdateEntityFeature.RELEASE_NOTES - ) _attr_display_precision = 2 # 40 byte chunks with ~200KB files increments by 0.02% + @staticmethod + @functools.cache + @override + def _convert_supported_features( + zha_features: ZHAUpdateEntityFeature, + ) -> UpdateEntityFeature: + """Convert ZHA update features to HA update features.""" + features = UpdateEntityFeature(0) + + if ZHAUpdateEntityFeature.INSTALL in zha_features: + features |= UpdateEntityFeature.INSTALL + if ZHAUpdateEntityFeature.SPECIFIC_VERSION in zha_features: + features |= UpdateEntityFeature.SPECIFIC_VERSION + if ZHAUpdateEntityFeature.PROGRESS in zha_features: + features |= UpdateEntityFeature.PROGRESS + if ZHAUpdateEntityFeature.BACKUP in zha_features: + features |= UpdateEntityFeature.BACKUP + if ZHAUpdateEntityFeature.RELEASE_NOTES in zha_features: + features |= UpdateEntityFeature.RELEASE_NOTES + + return features + def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: """Initialize the ZHA siren.""" zha_data = get_zha_data(entity_data.device_proxy.gateway_proxy.hass) @@ -124,7 +145,7 @@ def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: @override def installed_version(self) -> str | None: """Version installed and in use.""" - return self.entity_data.entity.installed_version + return self._zha_state.installed_version @property @override @@ -133,7 +154,7 @@ def in_progress(self) -> bool | None: Should return a boolean (True if in progress, False if not). """ - return self.entity_data.entity.in_progress + return self._zha_state.in_progress @property @override @@ -144,13 +165,13 @@ def update_percentage(self) -> int | float | None: Can either return a number to indicate the progress from 0 to 100% or None. """ - return self.entity_data.entity.update_percentage + return self._zha_state.update_percentage @property @override def latest_version(self) -> str | None: """Latest version available for install.""" - return self.entity_data.entity.latest_version + return self._zha_state.latest_version @property @override @@ -160,7 +181,7 @@ def release_summary(self) -> str | None: This is not suitable for long changelogs, but merely suitable for a short excerpt update description of max 255 characters. """ - return self.entity_data.entity.release_summary + return self._zha_state.release_summary @override async def async_release_notes(self) -> str | None: @@ -179,13 +200,13 @@ async def async_release_notes(self) -> str | None: "" ) - return f"{header}\n\n{self.entity_data.entity.release_notes or ''}" + return f"{header}\n\n{self._zha_state.release_notes or ''}" @property @override def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" - return self.entity_data.entity.release_url + return self._zha_state.release_url # We explicitly convert ZHA exceptions to HA exceptions here so there is no need to # use the `@convert_zha_error_to_ha_error()` decorator. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f1808976de8342..8d4ab68af2a048 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.30 +uv==0.11.31 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 1d5fd05fbd6659..6611d71a452930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.11.30", + "uv==0.11.31", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", diff --git a/requirements.txt b/requirements.txt index be5414d3fa5f8d..d12f5cef378d2d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==8.2.0 +infrared-protocols==8.2.1 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.30 +uv==0.11.31 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/requirements_all.txt b/requirements_all.txt index 47ee2bd1c321ab..4244f77e062095 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -193,7 +193,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==14.2.0 +aioamazondevices==14.2.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -1371,7 +1371,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==8.2.0 +infrared-protocols==8.2.1 # homeassistant.components.inkbird inkbird-ble==1.4.4 @@ -1586,7 +1586,7 @@ micloud==0.5 microBeesPy==0.3.5 # homeassistant.components.midea -midea-local==6.10.0 +midea-local==6.11.1 # homeassistant.components.mill mill-local==0.5.0 @@ -2638,7 +2638,7 @@ pythinkingcleaner==0.0.3 python-MotionMount==2.3.0 # homeassistant.components.aidot -python-aidot==0.3.53 +python-aidot==0.3.56 # homeassistant.components.awair python-awair==0.2.5 @@ -2831,9 +2831,6 @@ pyversasense==0.0.6 # homeassistant.components.vesync pyvesync==3.4.2 -# homeassistant.components.vizio -pyvizio==0.1.64 - # homeassistant.components.velux pyvlx==0.2.36 @@ -3329,6 +3326,9 @@ vilfo-api-client==0.5.0 # homeassistant.components.watts visionpluspython==1.1.0 +# homeassistant.components.vizio +vizaio==0.3.2 + # homeassistant.components.caldav vobject==0.9.9 @@ -3473,10 +3473,10 @@ zeroconf==0.150.0 zeversolar==0.3.2 # homeassistant.components.zha -zha-quirks==2.1.1 +zha-quirks==2.2.0 # homeassistant.components.zha -zha==2.0.1 +zha==2.1.0 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index 657f236fc74d3c..868c2e3f7285dd 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -56,4 +56,4 @@ types-pytz==2026.1.1.20260408 types-PyYAML==6.0.12.20260408 types-requests==2.33.0.20260408 types-xmltodict==1.0.1.20260408 -unidiff==0.7.5 +unidiff==1.0.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 6c76b7a71b3bd0..4245dbfb7ce2ee 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -3,4 +3,4 @@ codespell==2.4.3 ruff==0.15.22 yamllint==1.38.0 -zizmor==1.24.1 +zizmor==1.25.2 diff --git a/script/check_requirements/requirements.txt b/script/check_requirements/requirements.txt index ab805860ad812c..9d2db10613483b 100644 --- a/script/check_requirements/requirements.txt +++ b/script/check_requirements/requirements.txt @@ -1,3 +1,3 @@ PyGithub==2.9.1 requests==2.34.2 -unidiff==0.7.5 +unidiff==1.0.0 diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py index 84adf12806d658..13e797eb502126 100644 --- a/tests/components/airnow/conftest.py +++ b/tests/components/airnow/conftest.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.airnow.const import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonArrayType @@ -21,7 +21,7 @@ def config_entry_fixture( """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, - version=2, + version=3, entry_id="3bd2acb0e4f0476d40865546d0d91921", unique_id=f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}", data=config, @@ -44,9 +44,7 @@ def config_fixture() -> dict[str, Any]: @pytest.fixture(name="options") def options_fixture() -> dict[str, Any]: """Define a config options data fixture.""" - return { - CONF_RADIUS: 150, - } + return {} @pytest.fixture(name="data", scope="package") diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 72cb584adc6a35..336fa441f77679 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -30,7 +30,6 @@ 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, 'options': dict({ - 'radius': 150, }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, @@ -39,7 +38,7 @@ ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', - 'version': 2, + 'version': 3, }), }) # --- diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py index 759a21bc559d43..379b2a049cdb6a 100644 --- a/tests/components/airnow/test_config_flow.py +++ b/tests/components/airnow/test_config_flow.py @@ -1,7 +1,7 @@ """Test the AirNow config flow.""" from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError import pytest @@ -111,46 +111,46 @@ async def test_entry_already_exists( @pytest.mark.usefixtures("setup_airnow") -async def test_config_migration_v2(hass: HomeAssistant) -> None: - """Test that the config migration from Version 1 to Version 2 works.""" - config_entry = MockConfigEntry( - version=1, - domain=DOMAIN, - title="AirNow", - data={ - CONF_API_KEY: "1234", - CONF_LATITUDE: 33.6, - CONF_LONGITUDE: -118.1, - CONF_RADIUS: 25, - }, - source=config_entries.SOURCE_USER, - options={CONF_RADIUS: 10}, - unique_id="1234", - ) - config_entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.version == 2 - assert not config_entry.data.get(CONF_RADIUS) - assert config_entry.options.get(CONF_RADIUS) == 25 - - -@pytest.mark.usefixtures("setup_airnow") -async def test_options_flow(hass: HomeAssistant) -> None: - """Test that the options flow works.""" +@pytest.mark.parametrize( + ("version", "entry_data", "entry_options"), + [ + pytest.param( + 1, + { + CONF_API_KEY: "1234", + CONF_LATITUDE: 33.6, + CONF_LONGITUDE: -118.1, + CONF_RADIUS: 25, + }, + {}, + id="v1_radius_in_data", + ), + pytest.param( + 2, + { + CONF_API_KEY: "1234", + CONF_LATITUDE: 33.6, + CONF_LONGITUDE: -118.1, + }, + {CONF_RADIUS: 10}, + id="v2_radius_in_options", + ), + ], +) +async def test_config_migration( + hass: HomeAssistant, + version: int, + entry_data: dict[str, Any], + entry_options: dict[str, Any], +) -> None: + """Test that migration to Version 3 removes the radius option.""" config_entry = MockConfigEntry( - version=2, + version=version, domain=DOMAIN, title="AirNow", - data={ - CONF_API_KEY: "1234", - CONF_LATITUDE: 33.6, - CONF_LONGITUDE: -118.1, - }, + data=entry_data, source=config_entries.SOURCE_USER, - options={CONF_RADIUS: 10}, + options=entry_options, unique_id="1234", ) config_entry.add_to_hass(hass) @@ -158,23 +158,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - with patch( - "homeassistant.components.airnow.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_RADIUS: 25}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_RADIUS: 25, - } - assert len(mock_setup_entry.mock_calls) == 1 + assert config_entry.version == 3 + assert CONF_RADIUS not in config_entry.data + assert CONF_RADIUS not in config_entry.options diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index dccf5a8246901e..e1f9f3ed2bfe70 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -675,3 +675,83 @@ async def async_remove_config_entry_device( assert device_registry.async_get(device_entry_1.id).config_entries == { entry_1.entry_id } + + +async def test_list_linked_devices( + hass: HomeAssistant, + client: MockHAClientWebSocket, + device_registry: dr.DeviceRegistry, +) -> None: + """Test listing devices sharing a connection or identifier.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + + mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + + # device_1 shares its identifier with device_2 and its connection with device_3 + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + connections={mac}, + identifiers={("bridgeid", "0123")}, + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("bridgeid", "0123")}, + ) + device_3 = device_registry.async_get_or_create( + config_entry_id=entry_3.entry_id, + connections={mac}, + ) + # device_4 shares nothing with the others + device_4 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + identifiers={("bridgeid", "9999")}, + ) + assert len({device_1.id, device_2.id, device_3.id, device_4.id}) == 4 + + async def list_linked(device_id: str) -> dict: + await client.send_json_auto_id( + { + "type": "config/device_registry/list_linked_devices", + "device_id": device_id, + } + ) + return await client.receive_json() + + # device_1 is linked to both device_2 (identifier) and device_3 (connection) + msg = await list_linked(device_1.id) + assert msg["success"] + assert msg["result"]["linked_devices"] == unordered([device_2.id, device_3.id]) + + # device_2 and device_3 each only share with device_1, not with each other + msg = await list_linked(device_2.id) + assert msg["result"]["linked_devices"] == [device_1.id] + + msg = await list_linked(device_3.id) + assert msg["result"]["linked_devices"] == [device_1.id] + + # device_4 has no linked devices + msg = await list_linked(device_4.id) + assert msg["result"]["linked_devices"] == [] + + +async def test_list_linked_devices_unknown_device( + hass: HomeAssistant, + client: MockHAClientWebSocket, +) -> None: + """Test listing linked devices for an unknown device returns an error.""" + await client.send_json_auto_id( + { + "type": "config/device_registry/list_linked_devices", + "device_id": "does_not_exist", + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + assert msg["error"]["message"] == "Device not found" diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 29f5422d359e71..a241e700cf5c55 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -761,12 +761,14 @@ async def test_supervisor_issues_add_remove( "type": "reboot_required", "context": "system", "reference": None, + "reference_extra": None, "suggestions": [ { "uuid": uuid4().hex, "type": "execute_reboot", "context": "system", "reference": None, + "reference_extra": None, } ], }, @@ -801,6 +803,7 @@ async def test_supervisor_issues_add_remove( "type": "reboot_required", "context": "system", "reference": None, + "reference_extra": None, }, }, } @@ -873,6 +876,7 @@ async def test_supervisor_remove_missing_issue_without_error( "type": "reboot_required", "context": "system", "reference": None, + "reference_extra": None, }, }, } @@ -898,6 +902,143 @@ async def test_system_is_not_ready( assert not issues_coordinator.issues +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_app_port_conflict_single( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_supervisor_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issue for app port conflict with single execute_start suggestion.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + client = await hass_supervisor_ws_client() + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "app_port_conflict", + "context": "addon", + "reference": "test", + "reference_extra": {"port": 11443}, + "suggestions": [ + { + "uuid": uuid4().hex, + "type": "execute_start", + "context": "addon", + "reference": "test", + "reference_extra": {"port": 11443}, + } + ], + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid=issue_uuid, + context="addon", + type_="app_port_conflict", + fixable=True, + placeholders={ + "reference": "test", + "addon": "test", + "addon_url": "/hassio/addon/test", + "port": "11443", + }, + ) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_app_port_conflict_menu( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_supervisor_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issue for app port conflict with two suggestions.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + client = await hass_supervisor_ws_client() + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "app_port_conflict", + "context": "addon", + "reference": "test", + "reference_extra": {"port": 11443}, + "suggestions": [ + { + "uuid": uuid4().hex, + "type": "execute_start", + "context": "addon", + "reference": "test", + "reference_extra": {"port": 11443}, + }, + { + "uuid": uuid4().hex, + "type": "clear_port_config", + "context": "addon", + "reference": "test", + "reference_extra": {"port": 11443}, + }, + ], + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid=issue_uuid, + context="addon", + type_="app_port_conflict", + fixable=True, + placeholders={ + "reference": "test", + "addon": "test", + "addon_url": "/hassio/addon/test", + "port": "11443", + }, + ) + + @pytest.mark.parametrize( "all_setup_requests", [{"include_addons": True}], indirect=True ) @@ -926,6 +1067,7 @@ async def test_supervisor_issues_detached_addon_missing( "type": "detached_addon_missing", "context": "addon", "reference": "test", + "reference_extra": None, }, }, } @@ -977,12 +1119,14 @@ async def test_supervisor_issues_ntp_sync_failed( "type": "ntp_sync_failed", "context": "system", "reference": None, + "reference_extra": None, "suggestions": [ { "uuid": uuid4().hex, "type": "enable_ntp", "context": "system", "reference": None, + "reference_extra": None, } ], }, @@ -1032,6 +1176,7 @@ async def test_supervisor_issues_disk_lifetime( "type": "disk_lifetime", "context": "system", "reference": None, + "reference_extra": None, }, }, } @@ -1079,6 +1224,7 @@ async def test_supervisor_issues_free_space( "type": "free_space", "context": "system", "reference": None, + "reference_extra": None, }, }, } @@ -1133,6 +1279,7 @@ async def test_supervisor_issues_addon_pwned( "type": "pwned", "context": "addon", "reference": "test", + "reference_extra": None, }, }, } @@ -1201,6 +1348,7 @@ def _handle_subscription_event(event: IssueSubscriptionEvent) -> None: "type": "should_not_be_repair", "context": "system", "reference": None, + "reference_extra": None, }, }, } @@ -1221,6 +1369,7 @@ def _handle_subscription_event(event: IssueSubscriptionEvent) -> None: "type": "should_not_be_repair", "context": "system", "reference": "updated", + "reference_extra": None, }, }, } @@ -1241,6 +1390,7 @@ def _handle_subscription_event(event: IssueSubscriptionEvent) -> None: "type": "should_not_be_repair", "context": "system", "reference": "updated", + "reference_extra": None, }, }, } @@ -1366,6 +1516,7 @@ def _subscription_event(event: IssueSubscriptionEvent) -> None: "type": "should_not_be_repair", "context": "system", "reference": None, + "reference_extra": None, }, }, } diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index c0d026641571ce..93caa5a5e0286c 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -985,6 +985,205 @@ async def test_supervisor_issue_repair_flow_multiple_data_disks( supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issue_app_port_conflict_repair_flow_execute_start( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test fix flow for app port conflict with single execute_start suggestion.""" + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type="app_port_conflict", + context=ContextType.ADDON, + reference="test", + uuid=(issue_uuid := uuid4()), + reference_extra={"port": 11443}, + ), + ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type="execute_start", + context=ContextType.ADDON, + reference="test", + uuid=(sugg_uuid := uuid4()), + auto=False, + reference_extra={"port": 11443}, + ), + ] + }, + ) + + assert await async_setup_component(hass, DOMAIN, {}) + + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "addon_execute_start", + "data_schema": [], + "errors": None, + "description_placeholders": { + "reference": "test", + "addon": "test", + "port": "11443", + }, + "last_step": True, + "preview": None, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issue_app_port_conflict_repair_flow_menu( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test fix flow for app port conflict with two suggestions showing menu.""" + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type="app_port_conflict", + context=ContextType.ADDON, + reference="test", + uuid=(issue_uuid := uuid4()), + reference_extra={"port": 11443}, + ), + ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type="execute_start", + context=ContextType.ADDON, + reference="test", + uuid=uuid4(), + auto=False, + reference_extra={"port": 11443}, + ), + Suggestion( + type="clear_port_config", + context=ContextType.ADDON, + reference="test", + uuid=(clear_config_uuid := uuid4()), + auto=False, + reference_extra={"port": 11443}, + ), + ] + }, + ) + + assert await async_setup_component(hass, DOMAIN, {}) + + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "menu", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "fix_menu", + "data_schema": [ + { + "type": "select", + "options": [ + ["addon_execute_start", "addon_execute_start"], + ["addon_clear_port_config", "addon_clear_port_config"], + ], + "required": False, + "name": "next_step_id", + } + ], + "menu_options": ["addon_execute_start", "addon_clear_port_config"], + "description_placeholders": { + "reference": "test", + "addon": "test", + "port": "11443", + }, + } + + # Test clear_port_config path - automatically applies without confirmation + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "addon_clear_port_config"}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + # Since addon_clear_port_config does not require confirmation, it applies immediately + assert data == { + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with( + clear_config_uuid + ) + + @pytest.mark.parametrize( "all_setup_requests", [{"include_addons": True}], indirect=True ) diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index e0df951e451b61..e49f16ec9467b9 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -167,13 +167,16 @@ async def mock_create_stream(hass: HomeAssistant) -> Generator[AsyncMock]: async def async_get_image( - hass: HomeAssistant, width: int | None = None, height: int | None = None + hass: HomeAssistant, + width: int | None = None, + height: int | None = None, + expected_content_type: str = "image/jpeg", ) -> bytes: """Get the camera image.""" image = await camera.async_get_image( hass, "camera.my_camera", width=width, height=height ) - assert image.content_type == "image/jpeg" + assert image.content_type == expected_content_type return image.content @@ -693,9 +696,12 @@ async def test_camera_web_rtc( "answer": "v=0\r\ns=-\r\n", } - # Nest WebRTC cameras return a placeholder - await async_get_image(hass) - await async_get_image(hass, width=1024, height=768) + # The WebRTC placeholder is a PNG, served as image/png not the default jpeg. + png_bytes = await async_get_image(hass, expected_content_type="image/png") + assert png_bytes.startswith(b"\x89PNG") + await async_get_image( + hass, width=1024, height=768, expected_content_type="image/png" + ) @pytest.mark.usefixtures("auth", "camera_device") diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 50a0d6ba831a67..f4f8149d669c8c 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -338,6 +338,84 @@ async def test_dawn_defaults_to_civil( assert service_calls[0].data["type"] == "civil" +@pytest.mark.parametrize( + ("trigger_key", "astral_event"), + [ + ("sun.sunrise", SUN_EVENT_SUNRISE), + ("sun.sunset", SUN_EVENT_SUNSET), + ("sun.solar_noon", "noon"), + ("sun.solar_midnight", "midnight"), + ], +) +@pytest.mark.parametrize( + ("offset_type", "sign"), + [("before", -1), ("after", 1)], + ids=["before", "after"], +) +async def test_event_trigger_offset( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_key: str, + astral_event: str, + offset_type: str, + sign: int, +) -> None: + """Test the solar event triggers apply a before/after time offset.""" + offset = timedelta(hours=1) + with freeze_time(_TEST_DATETIME): + await _arm_automation( + hass, + { + "platform": trigger_key, + "options": {"offset": {"hours": 1}, "offset_type": offset_type}, + }, + {}, + ) + expected = get_astral_event_next( + hass, astral_event, _TEST_DATETIME, sign * offset + ) + # The offset shifts the fire time away from the bare event time. + assert expected != get_astral_event_next(hass, astral_event, _TEST_DATETIME) + + async_fire_time_changed(hass, expected + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + + +@pytest.mark.parametrize("trigger_key", ["sun.dawn", "sun.dusk"]) +@pytest.mark.parametrize( + ("offset_type", "sign"), + [("before", -1), ("after", 1)], + ids=["before", "after"], +) +async def test_dawn_dusk_trigger_offset( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_key: str, + offset_type: str, + sign: int, +) -> None: + """Test the dawn and dusk triggers apply a before/after time offset.""" + event = trigger_key.split(".")[1] + offset = timedelta(hours=1) + with freeze_time(_TEST_DATETIME): + await _arm_automation( + hass, + { + "platform": trigger_key, + "options": {"offset": {"hours": 1}, "offset_type": offset_type}, + }, + {}, + ) + expected = _DAWN_DUSK[event, "civil"] + sign * offset + + async_fire_time_changed(hass, expected + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + + # --- Edge cases: no matching solar event on the following day ---------------- # Longyearbyen, Svalbard (deep polar latitude) and Kotzebue, Alaska (above the diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index c2a8bd2a619d1b..d783ed922e5fa5 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -4,17 +4,16 @@ from unittest.mock import AsyncMock, patch import pytest -from pyvizio.api.apps import AppConfig -from pyvizio.const import DEVICE_CLASS_SPEAKER, MAX_VOLUME +from vizaio import AppConfig, InputInfo, SettingInfo, SettingType, VizioConnectionError +from vizaio.profiles import SOUNDBAR_PROFILE from homeassistant.components.vizio.const import DOMAIN from homeassistant.core import HomeAssistant from .const import ( ACCESS_TOKEN, - APP_LIST, - CH_TYPE, - CURRENT_APP_CONFIG, + APP_RECORDS, + CURRENT_APP_CONFIG_OBJ, CURRENT_EQ, CURRENT_INPUT, EQ_LIST, @@ -23,28 +22,20 @@ MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, MODEL, - RESPONSE_TOKEN, + PAIR_CHALLENGE, UNIQUE_ID, VERSION, - MockCompletePairingResponse, - MockStartPairingResponse, + audio_setting, ) from tests.common import MockConfigEntry -class MockInput: - """Mock Vizio device input.""" - - def __init__(self, name) -> None: - """Initialize mock Vizio device input.""" - self.meta_name = name - self.name = name - - -def get_mock_inputs(input_list) -> list[MockInput]: - """Return list of MockInput.""" - return [MockInput(device_input) for device_input in input_list] +def get_mock_inputs(input_list: list[str]) -> list[InputInfo]: + """Return list of InputInfo for the given input names.""" + return [ + InputInfo(name=name, meta_name=name, is_current=False) for name in input_list + ] @pytest.fixture @@ -78,7 +69,7 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) def vizio_get_unique_id_fixture() -> Generator[None]: """Mock get vizio unique ID.""" with patch( - "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id", + "homeassistant.components.vizio.config_flow.Vizio.get_serial_number", AsyncMock(return_value=UNIQUE_ID), ): yield @@ -87,9 +78,15 @@ def vizio_get_unique_id_fixture() -> Generator[None]: @pytest.fixture(name="vizio_data_coordinator_update", autouse=True) def vizio_data_coordinator_update_fixture() -> Generator[None]: """Mock get data coordinator update.""" - with patch( - "homeassistant.components.vizio.coordinator.gen_apps_list_from_url", - return_value=APP_LIST, + with ( + patch( + "homeassistant.components.vizio.coordinator.fetch_remote_app_catalog", + return_value=APP_RECORDS, + ), + patch( + "homeassistant.components.vizio.coordinator.fetch_app_availability", + return_value=(), + ), ): yield @@ -107,9 +104,15 @@ def no_delay_secs() -> Generator[None]: @pytest.fixture(name="vizio_data_coordinator_update_failure") def vizio_data_coordinator_update_failure_fixture() -> Generator[None]: """Mock get data coordinator update failure.""" - with patch( - "homeassistant.components.vizio.coordinator.gen_apps_list_from_url", - return_value=None, + with ( + patch( + "homeassistant.components.vizio.coordinator.fetch_remote_app_catalog", + side_effect=VizioConnectionError("fetch failed"), + ), + patch( + "homeassistant.components.vizio.coordinator.fetch_app_availability", + return_value=(), + ), ): yield @@ -118,8 +121,8 @@ def vizio_data_coordinator_update_failure_fixture() -> Generator[None]: def vizio_no_unique_id_fixture() -> Generator[None]: """Mock no vizio unique ID returrned.""" with patch( - "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id", - return_value=None, + "homeassistant.components.vizio.config_flow.Vizio.get_serial_number", + side_effect=VizioConnectionError("cannot connect"), ): yield @@ -127,9 +130,15 @@ def vizio_no_unique_id_fixture() -> Generator[None]: @pytest.fixture(name="vizio_connect") def vizio_connect_fixture() -> Generator[None]: """Mock valid vizio device and entry setup.""" - with patch( - "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config", - AsyncMock(return_value=True), + with ( + patch( + "homeassistant.components.vizio.config_flow.Vizio.ping", + AsyncMock(return_value=None), + ), + patch( + "homeassistant.components.vizio.config_flow.Vizio.ping_auth", + AsyncMock(return_value=None), + ), ): yield @@ -139,12 +148,12 @@ def vizio_complete_pairing_fixture() -> Generator[None]: """Mock complete vizio pairing workflow.""" with ( patch( - "homeassistant.components.vizio.config_flow.VizioAsync.start_pair", - return_value=MockStartPairingResponse(CH_TYPE, RESPONSE_TOKEN), + "homeassistant.components.vizio.config_flow.Vizio.begin_pair", + return_value=PAIR_CHALLENGE, ), patch( - "homeassistant.components.vizio.config_flow.VizioAsync.pair", - return_value=MockCompletePairingResponse(ACCESS_TOKEN), + "homeassistant.components.vizio.config_flow.Vizio.finish_pair", + return_value=ACCESS_TOKEN, ), ): yield @@ -154,8 +163,8 @@ def vizio_complete_pairing_fixture() -> Generator[None]: def vizio_start_pairing_failure_fixture() -> Generator[None]: """Mock vizio start pairing failure.""" with patch( - "homeassistant.components.vizio.config_flow.VizioAsync.start_pair", - return_value=None, + "homeassistant.components.vizio.config_flow.Vizio.begin_pair", + side_effect=VizioConnectionError("cannot connect"), ): yield @@ -165,12 +174,12 @@ def vizio_invalid_pin_failure_fixture() -> Generator[None]: """Mock vizio failure due to invalid pin.""" with ( patch( - "homeassistant.components.vizio.config_flow.VizioAsync.start_pair", - return_value=MockStartPairingResponse(CH_TYPE, RESPONSE_TOKEN), + "homeassistant.components.vizio.config_flow.Vizio.begin_pair", + return_value=PAIR_CHALLENGE, ), patch( - "homeassistant.components.vizio.config_flow.VizioAsync.pair", - return_value=None, + "homeassistant.components.vizio.config_flow.Vizio.finish_pair", + side_effect=VizioConnectionError("invalid pin"), ), ): yield @@ -188,31 +197,31 @@ def vizio_bypass_update_fixture() -> Generator[None]: """Mock component update with minimal data.""" with ( patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", + "homeassistant.components.vizio.Vizio.get_power_state", return_value=True, ), patch( - "homeassistant.components.vizio.VizioAsync.get_all_settings", + "homeassistant.components.vizio.Vizio.get_settings", return_value=None, ), patch( - "homeassistant.components.vizio.VizioAsync.get_current_input", + "homeassistant.components.vizio.Vizio.get_current_input", return_value=None, ), patch( - "homeassistant.components.vizio.VizioAsync.get_inputs_list", + "homeassistant.components.vizio.Vizio.get_inputs", return_value=None, ), patch( - "homeassistant.components.vizio.VizioAsync.get_current_app_config", + "homeassistant.components.vizio.Vizio.get_current_app_config", return_value=None, ), patch( - "homeassistant.components.vizio.VizioAsync.get_model_name", + "homeassistant.components.vizio.Vizio.get_model_name", return_value=None, ), patch( - "homeassistant.components.vizio.VizioAsync.get_version", + "homeassistant.components.vizio.Vizio.get_version", return_value=None, ), ): @@ -221,10 +230,10 @@ def vizio_bypass_update_fixture() -> Generator[None]: @pytest.fixture(name="vizio_guess_device_type") def vizio_guess_device_type_fixture() -> Generator[None]: - """Mock vizio async_guess_device_type function.""" + """Mock vizio device type probe to report a speaker.""" with patch( - "homeassistant.components.vizio.config_flow.async_guess_device_type", - return_value="speaker", + "homeassistant.components.vizio.config_flow.async_is_tv", + return_value=False, ): yield @@ -234,20 +243,24 @@ def vizio_cant_connect_fixture() -> Generator[None]: """Mock vizio device can't connect with valid auth.""" with ( patch( - "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config", - AsyncMock(return_value=False), + "homeassistant.components.vizio.config_flow.Vizio.ping", + side_effect=VizioConnectionError("cannot connect"), ), patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", - return_value=None, + "homeassistant.components.vizio.config_flow.Vizio.ping_auth", + side_effect=VizioConnectionError("cannot connect"), ), patch( - "homeassistant.components.vizio.VizioAsync.get_model_name", - return_value=None, + "homeassistant.components.vizio.Vizio.get_power_state", + side_effect=VizioConnectionError("cannot connect"), ), patch( - "homeassistant.components.vizio.VizioAsync.get_version", - return_value=None, + "homeassistant.components.vizio.Vizio.get_model_name", + side_effect=VizioConnectionError("cannot connect"), + ), + patch( + "homeassistant.components.vizio.Vizio.get_version", + side_effect=VizioConnectionError("cannot connect"), ), ): yield @@ -258,39 +271,46 @@ def vizio_update_fixture() -> Generator[None]: """Mock valid updates to vizio device.""" with ( patch( - "homeassistant.components.vizio.VizioAsync.get_all_settings", + "homeassistant.components.vizio.Vizio.get_settings", return_value={ - "volume": int(MAX_VOLUME[DEVICE_CLASS_SPEAKER] / 2), - "eq": CURRENT_EQ, - "mute": "Off", + "volume": audio_setting("volume", int(SOUNDBAR_PROFILE.max_volume / 2)), + "eq": audio_setting("eq", CURRENT_EQ), + "mute": audio_setting("mute", "Off"), }, ), patch( - "homeassistant.components.vizio.VizioAsync.get_setting_options", - return_value=EQ_LIST, + "homeassistant.components.vizio.Vizio.get_setting", + return_value=SettingInfo( + setting_type="audio", + name="eq", + value=CURRENT_EQ, + hashval=0, + type=SettingType.LIST, + options=tuple(EQ_LIST), + ), ), patch( - "homeassistant.components.vizio.VizioAsync.get_current_input", + "homeassistant.components.vizio.Vizio.get_current_input", return_value=CURRENT_INPUT, ), patch( - "homeassistant.components.vizio.VizioAsync.get_inputs_list", + "homeassistant.components.vizio.Vizio.get_inputs", return_value=get_mock_inputs(INPUT_LIST), ), patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", + "homeassistant.components.vizio.Vizio.get_power_state", return_value=True, ), patch( - "homeassistant.components.vizio.VizioAsync.get_model_name", + "homeassistant.components.vizio.Vizio.get_model_name", return_value=MODEL, ), patch( - "homeassistant.components.vizio.VizioAsync.get_version", + "homeassistant.components.vizio.Vizio.get_version", return_value=VERSION, ), patch( - "homeassistant.components.vizio.VizioAsync.get_current_app_config", + "homeassistant.components.vizio.Vizio.get_current_app_config", return_value=None, ), ): @@ -302,16 +322,16 @@ def vizio_update_with_apps_fixture(vizio_update: None) -> Generator[None]: """Mock valid updates to vizio device that supports apps.""" with ( patch( - "homeassistant.components.vizio.VizioAsync.get_inputs_list", + "homeassistant.components.vizio.Vizio.get_inputs", return_value=get_mock_inputs(INPUT_LIST_WITH_APPS), ), patch( - "homeassistant.components.vizio.VizioAsync.get_current_input", + "homeassistant.components.vizio.Vizio.get_current_input", return_value="CAST", ), patch( - "homeassistant.components.vizio.VizioAsync.get_current_app_config", - return_value=AppConfig(**CURRENT_APP_CONFIG), + "homeassistant.components.vizio.Vizio.get_current_app_config", + return_value=CURRENT_APP_CONFIG_OBJ, ), ): yield @@ -322,16 +342,16 @@ def vizio_update_with_apps_on_input_fixture(vizio_update: None) -> Generator[Non """Mock valid updates to vizio device that supports apps but is on a TV input.""" with ( patch( - "homeassistant.components.vizio.VizioAsync.get_inputs_list", + "homeassistant.components.vizio.Vizio.get_inputs", return_value=get_mock_inputs(INPUT_LIST_WITH_APPS), ), patch( - "homeassistant.components.vizio.VizioAsync.get_current_input", + "homeassistant.components.vizio.Vizio.get_current_input", return_value=CURRENT_INPUT, ), patch( - "homeassistant.components.vizio.VizioAsync.get_current_app_config", - return_value=AppConfig("unknown", 1, "app"), + "homeassistant.components.vizio.Vizio.get_current_app_config", + return_value=AppConfig(app_id="unknown", name_space=1, message="app"), ), ): yield diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 5fbf61a58da65a..7ca882997ca492 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -2,6 +2,9 @@ from ipaddress import ip_address +from vizaio import AppConfig, AppRecord, PairChallenge, SettingInfo, SettingType +from vizaio.profiles import SOUNDBAR_PROFILE, TV_PROFILE + from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, MediaPlayerDeviceClass, @@ -39,26 +42,28 @@ MODEL = "model" VERSION = "version" -CH_TYPE = 1 -RESPONSE_TOKEN = 1234 PIN = "abcd" +PAIR_CHALLENGE = PairChallenge(challenge_type=1, token=1234) -class MockStartPairingResponse: - """Mock Vizio start pairing response.""" - - def __init__(self, ch_type: int, token: int) -> None: - """Initialize mock start pairing response.""" - self.ch_type = ch_type - self.token = token - +MAX_VOLUME = { + MediaPlayerDeviceClass.TV: TV_PROFILE.max_volume, + MediaPlayerDeviceClass.SPEAKER: SOUNDBAR_PROFILE.max_volume, +} -class MockCompletePairingResponse: - """Mock Vizio complete pairing response.""" - def __init__(self, auth_token: str) -> None: - """Initialize mock complete pairing response.""" - self.auth_token = auth_token +def audio_setting( + name: str, value: int | str, options: tuple[str, ...] = () +) -> SettingInfo: + """Build an audio SettingInfo for mock device responses.""" + return SettingInfo( + setting_type="audio", + name=name, + value=value, + hashval=0, + type=SettingType.SLIDER if isinstance(value, int) else SettingType.LIST, + options=options, + ) CURRENT_EQ = "Music" @@ -69,23 +74,25 @@ def __init__(self, auth_token: str) -> None: CURRENT_APP = "Hulu" CURRENT_APP_CONFIG = {CONF_APP_ID: "3", CONF_NAME_SPACE: 4, CONF_MESSAGE: None} -APP_LIST = [ - { - "name": "Hulu", - "country": ["*"], - "id": ["1"], - "config": [{"NAME_SPACE": 4, "APP_ID": "3", "MESSAGE": None}], - }, - { - "name": "Netflix", - "country": ["*"], - "id": ["2"], - "config": [{"NAME_SPACE": 1, "APP_ID": "2", "MESSAGE": None}], - }, -] -APP_NAME_LIST = [app["name"] for app in APP_LIST] +CURRENT_APP_CONFIG_OBJ = AppConfig(app_id="3", name_space=4, message=None) +APP_RECORDS = ( + AppRecord( + name="Hulu", + country=("*",), + config=(AppConfig(app_id="3", name_space=4, message=None),), + id="1", + ), + AppRecord( + name="Netflix", + country=("*",), + config=(AppConfig(app_id="2", name_space=1, message=None),), + id="2", + ), +) +APP_NAME_LIST = [app.name for app in APP_RECORDS] INPUT_LIST_WITH_APPS = [*INPUT_LIST, "CAST"] CUSTOM_CONFIG = {CONF_APP_ID: "test", CONF_MESSAGE: None, CONF_NAME_SPACE: 10} +CUSTOM_CONFIG_OBJ = AppConfig(app_id="test", name_space=10, message=None) ADDITIONAL_APP_CONFIG = { "name": CURRENT_APP, CONF_CONFIG: CUSTOM_CONFIG, @@ -95,6 +102,7 @@ def __init__(self, auth_token: str) -> None: "NAME_SPACE": 10, "MESSAGE": None, } +UNKNOWN_APP_CONFIG_OBJ = AppConfig(app_id="UNKNOWN", name_space=10, message=None) ENTITY_ID = f"{MP_DOMAIN}.{slugify(NAME)}" diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index b3716f9e9dfbcc..8582b5a5da8795 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -23,7 +23,7 @@ from homeassistant.helpers import device_registry as dr from .conftest import setup_integration -from .const import APP_LIST, HOST2, MODEL, NAME2, UNIQUE_ID, VERSION +from .const import APP_RECORDS, HOST2, MODEL, NAME2, UNIQUE_ID, VERSION from tests.common import MockConfigEntry, async_fire_time_changed @@ -116,8 +116,8 @@ async def test_apps_coordinator_persists_until_last_tv_unloads( await hass.async_block_till_done() with patch( - "homeassistant.components.vizio.coordinator.gen_apps_list_from_url", - return_value=APP_LIST, + "homeassistant.components.vizio.coordinator.fetch_remote_app_catalog", + return_value=APP_RECORDS, ) as mock_fetch: freezer.tick(timedelta(days=1)) async_fire_time_changed(hass) @@ -129,8 +129,8 @@ async def test_apps_coordinator_persists_until_last_tv_unloads( await hass.async_block_till_done() with patch( - "homeassistant.components.vizio.coordinator.gen_apps_list_from_url", - return_value=APP_LIST, + "homeassistant.components.vizio.coordinator.fetch_remote_app_catalog", + return_value=APP_RECORDS, ) as mock_fetch: freezer.tick(timedelta(days=2)) async_fire_time_changed(hass) diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index a8928ca06ef5f0..f6f02754a26832 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -8,16 +8,9 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from pyvizio.api.apps import AppConfig -from pyvizio.const import ( - APPS, - DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER, - DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV, - INPUT_APPS, - MAX_VOLUME, - UNKNOWN_APP, -) from syrupy.assertion import SnapshotAssertion +from vizaio import AppConfig, RemoteKey, VizioConnectionError +from vizaio.apps import BUNDLED_APPS, UNKNOWN_APP, is_app_input from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, @@ -64,24 +57,27 @@ from .conftest import setup_integration from .const import ( ADDITIONAL_APP_CONFIG, - APP_LIST, APP_NAME_LIST, + APP_RECORDS, CURRENT_APP, - CURRENT_APP_CONFIG, + CURRENT_APP_CONFIG_OBJ, CURRENT_EQ, CURRENT_INPUT, - CUSTOM_CONFIG, + CUSTOM_CONFIG_OBJ, ENTITY_ID, EQ_LIST, INPUT_LIST, INPUT_LIST_WITH_APPS, + MAX_VOLUME, MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, MOCK_TV_WITH_EXCLUDE_CONFIG, MOCK_TV_WITH_INCLUDE_CONFIG, NAME, UNIQUE_ID, UNKNOWN_APP_CONFIG, + UNKNOWN_APP_CONFIG_OBJ, VOLUME_STEP, + audio_setting, ) from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -124,7 +120,9 @@ def _get_ha_power_state(vizio_power_state: bool) -> str: return STATE_OFF -def _assert_sources_and_volume(attr: dict[str, Any], vizio_device_class: str) -> None: +def _assert_sources_and_volume( + attr: dict[str, Any], vizio_device_class: MediaPlayerDeviceClass +) -> None: """Assert source list, source, and volume level based on device class.""" assert attr[ATTR_INPUT_SOURCE_LIST] == INPUT_LIST assert attr[ATTR_INPUT_SOURCE] == CURRENT_INPUT @@ -154,15 +152,17 @@ async def _cm_for_test_setup_without_apps( """Context manager to setup test for Vizio devices without app patches.""" with ( patch( - "homeassistant.components.vizio.VizioAsync.get_all_settings", - return_value=all_settings, + "homeassistant.components.vizio.Vizio.get_settings", + return_value={ + name: audio_setting(name, value) for name, value in all_settings.items() + }, ), patch( - "homeassistant.components.vizio.VizioAsync.get_setting_options", - return_value=EQ_LIST, + "homeassistant.components.vizio.Vizio.get_setting", + return_value=audio_setting("eq", CURRENT_EQ, tuple(EQ_LIST)), ), patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", + "homeassistant.components.vizio.Vizio.get_power_state", return_value=vizio_power_state, ), ): @@ -177,7 +177,7 @@ async def _test_setup_tv( async with _cm_for_test_setup_without_apps( { - "volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2), + "volume": int(MAX_VOLUME[MediaPlayerDeviceClass.TV] / 2), "mute": "Off", "eq": CURRENT_EQ, }, @@ -189,7 +189,7 @@ async def _test_setup_tv( hass, MediaPlayerDeviceClass.TV, ha_power_state ) if ha_power_state == STATE_ON: - _assert_sources_and_volume(attr, VIZIO_DEVICE_CLASS_TV) + _assert_sources_and_volume(attr, MediaPlayerDeviceClass.TV) assert attr[ATTR_SOUND_MODE] == CURRENT_EQ @@ -200,7 +200,7 @@ async def _test_setup_speaker( ha_power_state = _get_ha_power_state(vizio_power_state) audio_settings = { - "volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_SPEAKER] / 2), + "volume": int(MAX_VOLUME[MediaPlayerDeviceClass.SPEAKER] / 2), "mute": "Off", "eq": CURRENT_EQ, } @@ -215,22 +215,22 @@ async def _test_setup_speaker( hass, MediaPlayerDeviceClass.SPEAKER, ha_power_state ) if ha_power_state == STATE_ON: - _assert_sources_and_volume(attr, VIZIO_DEVICE_CLASS_SPEAKER) + _assert_sources_and_volume(attr, MediaPlayerDeviceClass.SPEAKER) assert "sound_mode" in attr @asynccontextmanager async def _cm_for_test_setup_tv_with_apps( - hass: HomeAssistant, config_entry: MockConfigEntry, app_config: dict[str, Any] + hass: HomeAssistant, config_entry: MockConfigEntry, app_config: AppConfig | None ) -> AsyncIterator[None]: """Context manager to setup test for Vizio TV with support for apps.""" async with _cm_for_test_setup_without_apps( - {"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2), "mute": "Off"}, + {"volume": int(MAX_VOLUME[MediaPlayerDeviceClass.TV] / 2), "mute": "Off"}, True, ): with patch( - "homeassistant.components.vizio.VizioAsync.get_current_app_config", - return_value=AppConfig(**app_config), + "homeassistant.components.vizio.Vizio.get_current_app_config", + return_value=app_config, ): await setup_integration(hass, config_entry) @@ -239,8 +239,8 @@ async def _cm_for_test_setup_tv_with_apps( ) assert ( attr["volume_level"] - == float(int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2)) - / MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] + == float(int(MAX_VOLUME[MediaPlayerDeviceClass.TV] / 2)) + / MAX_VOLUME[MediaPlayerDeviceClass.TV] ) yield @@ -249,10 +249,8 @@ async def _cm_for_test_setup_tv_with_apps( def _assert_source_list_with_apps( list_to_test: list[str], attr: dict[str, Any] ) -> None: - """Assert source list matches list_to_test after removing INPUT_APPS from list.""" - for app_to_remove in INPUT_APPS: - if app_to_remove in list_to_test: - list_to_test.remove(app_to_remove) + """Assert source list matches list_to_test after removing app inputs.""" + list_to_test = [item for item in list_to_test if not is_app_input(item)] assert attr[ATTR_INPUT_SOURCE_LIST] == list_to_test @@ -267,13 +265,12 @@ async def _test_service( **kwargs, ) -> None: """Test generic Vizio media player entity service.""" - kwargs["log_api_exception"] = False service_data = {ATTR_ENTITY_ID: ENTITY_ID} if additional_service_data: service_data.update(additional_service_data) with patch( - f"homeassistant.components.vizio.VizioAsync.{vizio_func_name}" + f"homeassistant.components.vizio.Vizio.{vizio_func_name}" ) as service_call: await hass.services.async_call( domain, @@ -348,19 +345,19 @@ async def test_services( """Test all Vizio media player entity services.""" await _test_setup_tv(hass, mock_tv_config_entry, True) - await _test_service(hass, MP_DOMAIN, "pow_on", SERVICE_TURN_ON, None) - await _test_service(hass, MP_DOMAIN, "pow_off", SERVICE_TURN_OFF, None) + await _test_service(hass, MP_DOMAIN, "power_on", SERVICE_TURN_ON, None) + await _test_service(hass, MP_DOMAIN, "power_off", SERVICE_TURN_OFF, None) await _test_service( hass, MP_DOMAIN, - "mute_on", + "mute", SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}, ) await _test_service( hass, MP_DOMAIN, - "mute_off", + "unmute", SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: False}, ) @@ -373,29 +370,43 @@ async def test_services( "USB", ) await _test_service( - hass, MP_DOMAIN, "vol_up", SERVICE_VOLUME_UP, None, num=DEFAULT_VOLUME_STEP + hass, MP_DOMAIN, "volume_up", SERVICE_VOLUME_UP, None, steps=DEFAULT_VOLUME_STEP ) await _test_service( - hass, MP_DOMAIN, "vol_down", SERVICE_VOLUME_DOWN, None, num=DEFAULT_VOLUME_STEP + hass, + MP_DOMAIN, + "volume_down", + SERVICE_VOLUME_DOWN, + None, + steps=DEFAULT_VOLUME_STEP, ) await _test_service( hass, MP_DOMAIN, - "vol_up", + "volume_up", SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 1}, - num=50, # From 50% to 100% = 50 steps (TV max volume 100, starting at 50) + steps=50, # From 50% to 100% = 50 steps (TV max volume 100, starting at 50) ) await _test_service( hass, MP_DOMAIN, - "vol_down", + "volume_down", SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0}, - num=100, # From 100% (after previous vol_up) to 0% = 100 steps + steps=100, # From 100% (after previous vol_up) to 0% = 100 steps + ) + await _test_service( + hass, MP_DOMAIN, "send_key", SERVICE_MEDIA_NEXT_TRACK, None, RemoteKey.CH_UP + ) + await _test_service( + hass, + MP_DOMAIN, + "send_key", + SERVICE_MEDIA_PREVIOUS_TRACK, + None, + RemoteKey.CH_DOWN, ) - await _test_service(hass, MP_DOMAIN, "ch_up", SERVICE_MEDIA_NEXT_TRACK, None) - await _test_service(hass, MP_DOMAIN, "ch_down", SERVICE_MEDIA_PREVIOUS_TRACK, None) await _test_service( hass, MP_DOMAIN, @@ -428,8 +439,12 @@ async def test_services( "eq", "Music", ) - await _test_service(hass, MP_DOMAIN, "play", SERVICE_MEDIA_PLAY, None) - await _test_service(hass, MP_DOMAIN, "pause", SERVICE_MEDIA_PAUSE, None) + await _test_service( + hass, MP_DOMAIN, "send_key", SERVICE_MEDIA_PLAY, None, RemoteKey.PLAY + ) + await _test_service( + hass, MP_DOMAIN, "send_key", SERVICE_MEDIA_PAUSE, None, RemoteKey.PAUSE + ) @pytest.mark.usefixtures("vizio_connect", "vizio_update") @@ -450,7 +465,7 @@ async def test_options_update( assert config_entry.options == updated_options await hass.async_block_till_done() await _test_service( - hass, MP_DOMAIN, "vol_up", SERVICE_VOLUME_UP, None, num=VOLUME_STEP + hass, MP_DOMAIN, "volume_up", SERVICE_VOLUME_UP, None, steps=VOLUME_STEP ) @@ -465,8 +480,8 @@ async def test_update_available_to_unavailable( # Simulate device becoming unreachable with patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", - return_value=None, + "homeassistant.components.vizio.Vizio.get_power_state", + side_effect=VizioConnectionError("cannot connect"), ): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -485,8 +500,8 @@ async def test_update_unavailable_to_available( # First, make device unavailable with patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", - return_value=None, + "homeassistant.components.vizio.Vizio.get_power_state", + side_effect=VizioConnectionError("cannot connect"), ): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -495,7 +510,7 @@ async def test_update_unavailable_to_available( # Then, make device available again with patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", + "homeassistant.components.vizio.Vizio.get_power_state", return_value=True, ): freezer.tick(timedelta(minutes=1)) @@ -512,7 +527,7 @@ async def test_setup_with_apps( ) -> None: """Test device setup with apps.""" async with _cm_for_test_setup_tv_with_apps( - hass, mock_tv_config_entry, CURRENT_APP_CONFIG + hass, mock_tv_config_entry, CURRENT_APP_CONFIG_OBJ ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) @@ -528,7 +543,6 @@ async def test_setup_with_apps( SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: CURRENT_APP}, CURRENT_APP, - APP_LIST, ) @@ -541,7 +555,9 @@ async def test_setup_with_apps_include( config_entry = MockConfigEntry( domain=DOMAIN, data=MOCK_TV_WITH_INCLUDE_CONFIG, unique_id=UNIQUE_ID ) - async with _cm_for_test_setup_tv_with_apps(hass, config_entry, CURRENT_APP_CONFIG): + async with _cm_for_test_setup_tv_with_apps( + hass, config_entry, CURRENT_APP_CONFIG_OBJ + ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps([*INPUT_LIST_WITH_APPS, CURRENT_APP], attr) assert CURRENT_APP in attr[ATTR_INPUT_SOURCE_LIST] @@ -559,7 +575,9 @@ async def test_setup_with_apps_exclude( config_entry = MockConfigEntry( domain=DOMAIN, data=MOCK_TV_WITH_EXCLUDE_CONFIG, unique_id=UNIQUE_ID ) - async with _cm_for_test_setup_tv_with_apps(hass, config_entry, CURRENT_APP_CONFIG): + async with _cm_for_test_setup_tv_with_apps( + hass, config_entry, CURRENT_APP_CONFIG_OBJ + ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps([*INPUT_LIST_WITH_APPS, CURRENT_APP], attr) assert CURRENT_APP in attr[ATTR_INPUT_SOURCE_LIST] @@ -580,7 +598,7 @@ async def test_setup_with_apps_additional_apps_config( async with _cm_for_test_setup_tv_with_apps( hass, config_entry, - ADDITIONAL_APP_CONFIG["config"], + CUSTOM_CONFIG_OBJ, ): attr = hass.states.get(ENTITY_ID).attributes assert attr[ATTR_INPUT_SOURCE_LIST].count(CURRENT_APP) == 1 @@ -610,7 +628,6 @@ async def test_setup_with_apps_additional_apps_config( SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: "Netflix"}, "Netflix", - APP_LIST, ) await _test_service( hass, @@ -618,14 +635,14 @@ async def test_setup_with_apps_additional_apps_config( "launch_app_config", SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: CURRENT_APP}, - **CUSTOM_CONFIG, + CUSTOM_CONFIG_OBJ, ) # Test that invalid app does nothing with ( - patch("homeassistant.components.vizio.VizioAsync.launch_app") as service_call1, + patch("homeassistant.components.vizio.Vizio.launch_app") as service_call1, patch( - "homeassistant.components.vizio.VizioAsync.launch_app_config" + "homeassistant.components.vizio.Vizio.launch_app_config" ) as service_call2, ): await hass.services.async_call( @@ -646,7 +663,7 @@ async def test_setup_with_unknown_app_config( ) -> None: """Test device setup with apps where app config returned is unknown.""" async with _cm_for_test_setup_tv_with_apps( - hass, mock_tv_config_entry, UNKNOWN_APP_CONFIG + hass, mock_tv_config_entry, UNKNOWN_APP_CONFIG_OBJ ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) @@ -662,9 +679,7 @@ async def test_setup_with_no_running_app( caplog: pytest.LogCaptureFixture, ) -> None: """Test device setup with apps where no app is running.""" - async with _cm_for_test_setup_tv_with_apps( - hass, mock_tv_config_entry, vars(AppConfig()) - ): + async with _cm_for_test_setup_tv_with_apps(hass, mock_tv_config_entry, None): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) assert attr[ATTR_INPUT_SOURCE] == "CAST" @@ -678,13 +693,13 @@ async def test_setup_tv_without_mute( ) -> None: """Test Vizio TV entity setup when mute property isn't returned by Vizio API.""" async with _cm_for_test_setup_without_apps( - {"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2)}, + {"volume": int(MAX_VOLUME[MediaPlayerDeviceClass.TV] / 2)}, True, ): await setup_integration(hass, mock_tv_config_entry) attr = _get_attr_and_assert_base_attr(hass, MediaPlayerDeviceClass.TV, STATE_ON) - _assert_sources_and_volume(attr, VIZIO_DEVICE_CLASS_TV) + _assert_sources_and_volume(attr, MediaPlayerDeviceClass.TV) assert "sound_mode" not in attr assert "is_volume_muted" not in attr @@ -697,21 +712,19 @@ async def test_apps_update( ) -> None: """Test device setup with apps where no app is running.""" with patch( - "homeassistant.components.vizio.coordinator.gen_apps_list_from_url", - return_value=None, + "homeassistant.components.vizio.coordinator.fetch_remote_app_catalog", + side_effect=VizioConnectionError("fetch failed"), ): - async with _cm_for_test_setup_tv_with_apps( - hass, mock_tv_config_entry, vars(AppConfig()) - ): + async with _cm_for_test_setup_tv_with_apps(hass, mock_tv_config_entry, None): # Check source list, remove TV inputs, and verify that the integration is - # using the default APPS list + # using the default bundled apps list sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] apps = list(set(sources) - set(INPUT_LIST)) - assert len(apps) == len(APPS) + assert len(apps) == len(BUNDLED_APPS) with patch( - "homeassistant.components.vizio.coordinator.gen_apps_list_from_url", - return_value=APP_LIST, + "homeassistant.components.vizio.coordinator.fetch_remote_app_catalog", + return_value=APP_RECORDS, ): async_fire_time_changed(hass, dt_util.now() + timedelta(days=2)) await hass.async_block_till_done() @@ -719,10 +732,10 @@ async def test_apps_update( await hass.async_block_till_done() # Check source list, remove TV inputs, and verify that # the integration is - # now using the APP_LIST list + # now using the APP_RECORDS list sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] apps = list(set(sources) - set(INPUT_LIST)) - assert len(apps) == len(APP_LIST) + assert len(apps) == len(APP_RECORDS) @pytest.mark.usefixtures("vizio_connect", "vizio_update_with_apps_on_input") @@ -752,7 +765,7 @@ async def test_coordinator_update_on_to_off( # Device turns off with patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", + "homeassistant.components.vizio.Vizio.get_power_state", return_value=False, ): freezer.tick(timedelta(minutes=1)) @@ -808,11 +821,14 @@ async def test_sound_mode_feature_toggling( # Update with audio settings that have no sound mode with ( patch( - "homeassistant.components.vizio.VizioAsync.get_all_settings", - return_value={"volume": 50, "mute": "Off"}, + "homeassistant.components.vizio.Vizio.get_settings", + return_value={ + "volume": audio_setting("volume", 50), + "mute": audio_setting("mute", "Off"), + }, ), patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", + "homeassistant.components.vizio.Vizio.get_power_state", return_value=True, ), ): @@ -842,11 +858,11 @@ async def test_sound_mode_list_cached( # Update with different sound mode options — cached list should persist with ( patch( - "homeassistant.components.vizio.VizioAsync.get_setting_options", - return_value=["Different1", "Different2"], + "homeassistant.components.vizio.Vizio.get_setting", + return_value=audio_setting("eq", CURRENT_EQ, ("Different1", "Different2")), ), patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", + "homeassistant.components.vizio.Vizio.get_power_state", return_value=True, ), ): diff --git a/tests/components/vizio/test_remote.py b/tests/components/vizio/test_remote.py index d3fe4a900c7c31..f9410eb502bf2b 100644 --- a/tests/components/vizio/test_remote.py +++ b/tests/components/vizio/test_remote.py @@ -68,7 +68,7 @@ async def test_remote_is_off_when_device_off( ) -> None: """Test remote state is off when device is off.""" with patch( - "homeassistant.components.vizio.VizioAsync.get_power_state", + "homeassistant.components.vizio.Vizio.get_power_state", return_value=False, ): await setup_integration(hass, mock_speaker_config_entry) @@ -79,8 +79,8 @@ async def test_remote_is_off_when_device_off( @pytest.mark.parametrize( ("service", "mock_method"), [ - (SERVICE_TURN_ON, "pow_on"), - (SERVICE_TURN_OFF, "pow_off"), + (SERVICE_TURN_ON, "power_on"), + (SERVICE_TURN_OFF, "power_off"), ], ) @pytest.mark.usefixtures("vizio_connect", "vizio_update") @@ -93,7 +93,7 @@ async def test_turn_on_off( """Test turning on/off the remote sends the correct power command.""" await setup_integration(hass, mock_speaker_config_entry) with patch( - f"homeassistant.components.vizio.VizioAsync.{mock_method}", + f"homeassistant.components.vizio.Vizio.{mock_method}", ) as mock_power: await hass.services.async_call( REMOTE_DOMAIN, @@ -101,7 +101,7 @@ async def test_turn_on_off( {ATTR_ENTITY_ID: REMOTE_ENTITY_ID}, blocking=True, ) - mock_power.assert_called_once_with(log_api_exception=False) + mock_power.assert_called_once_with() @pytest.mark.parametrize( @@ -111,7 +111,7 @@ async def test_turn_on_off( ("ch_up", "CH_UP"), ("SMARTCAST", "SMARTCAST"), # Aliases - ("closed_captions", "CC_TOGGLE"), + ("next_input", "INPUT_NEXT"), ("channel_up", "CH_UP"), ("enter", "OK"), ("volume_down", "VOL_DOWN"), @@ -128,7 +128,7 @@ async def test_send_command_tv_valid( """Test send_command resolves valid TV commands.""" await setup_integration(hass, mock_tv_config_entry) with patch( - "homeassistant.components.vizio.VizioAsync.remote", + "homeassistant.components.vizio.Vizio.send_key", ) as mock_remote: await hass.services.async_call( REMOTE_DOMAIN, @@ -139,7 +139,7 @@ async def test_send_command_tv_valid( }, blocking=True, ) - mock_remote.assert_called_once_with(expected_key, log_api_exception=False) + mock_remote.assert_called_once_with(expected_key) @pytest.mark.parametrize("command", ["INVALID_KEY", "not_a_key"]) @@ -185,7 +185,7 @@ async def test_send_command_speaker_valid( """Test send_command resolves valid speaker commands.""" await setup_integration(hass, mock_speaker_config_entry) with patch( - "homeassistant.components.vizio.VizioAsync.remote", + "homeassistant.components.vizio.Vizio.send_key", ) as mock_remote: await hass.services.async_call( REMOTE_DOMAIN, @@ -196,7 +196,7 @@ async def test_send_command_speaker_valid( }, blocking=True, ) - mock_remote.assert_called_once_with(expected_key, log_api_exception=False) + mock_remote.assert_called_once_with(expected_key) @pytest.mark.parametrize( @@ -237,7 +237,7 @@ async def test_send_command_multiple( """Test send_command with multiple commands in one call.""" await setup_integration(hass, mock_tv_config_entry) with patch( - "homeassistant.components.vizio.VizioAsync.remote", + "homeassistant.components.vizio.Vizio.send_key", ) as mock_remote: await hass.services.async_call( REMOTE_DOMAIN, @@ -249,8 +249,8 @@ async def test_send_command_multiple( blocking=True, ) assert mock_remote.call_count == 2 - mock_remote.assert_any_call("UP", log_api_exception=False) - mock_remote.assert_any_call("OK", log_api_exception=False) + mock_remote.assert_any_call("UP") + mock_remote.assert_any_call("OK") @pytest.mark.usefixtures("vizio_connect", "vizio_update") @@ -261,7 +261,7 @@ async def test_send_command_invalid_skips_valid( await setup_integration(hass, mock_tv_config_entry) with ( patch( - "homeassistant.components.vizio.VizioAsync.remote", + "homeassistant.components.vizio.Vizio.send_key", ) as mock_remote, pytest.raises(ServiceValidationError), ): @@ -285,7 +285,7 @@ async def test_send_command_delay_between_repeats( await setup_integration(hass, mock_tv_config_entry) with ( patch( - "homeassistant.components.vizio.VizioAsync.remote", + "homeassistant.components.vizio.Vizio.send_key", ) as mock_remote, patch( "homeassistant.components.vizio.remote.asyncio.sleep", diff --git a/tests/components/wiim/conftest.py b/tests/components/wiim/conftest.py index 6a0b5c6684b4ac..2793d16e73016e 100644 --- a/tests/components/wiim/conftest.py +++ b/tests/components/wiim/conftest.py @@ -130,6 +130,7 @@ def set_available(available: bool) -> None: mock.async_get_queue_snapshot = AsyncMock( return_value=WiimQueueSnapshot(items=()) ) + mock.as_diagnostics = MagicMock() mock.build_loop_mode = MagicMock( return_value=LoopMode.SHUFFLE_DISABLE_REPEAT_NONE ) diff --git a/tests/components/wiim/snapshots/test_diagnostics.ambr b/tests/components/wiim/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..c58e7f56eebe23 --- /dev/null +++ b/tests/components/wiim/snapshots/test_diagnostics.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics_redacts_identifiers + dict({ + 'device': dict({ + 'available': True, + 'event_subscriptions_active': True, + 'firmware_version': '4.8.523456', + 'input_modes': list([ + 'Line In', + ]), + 'ip_address': '**REDACTED**', + 'manufacturer': 'Linkplay Tech', + 'model_name': 'WiiM Pro', + 'muted': False, + 'name': '**REDACTED**', + 'output_mode': 'speaker', + 'output_modes': list([ + 'Speaker Out', + ]), + 'play_mode': 'Network', + 'presentation_url_available': True, + 'supports_http_api': True, + 'udn': '**REDACTED**', + 'volume': 50, + }), + 'multiroom': dict({ + 'leader_udn': '**REDACTED**', + 'member_udns': '**REDACTED**', + 'role': 'standalone', + }), + }) +# --- diff --git a/tests/components/wiim/test_diagnostics.py b/tests/components/wiim/test_diagnostics.py new file mode 100644 index 00000000000000..b18744e9aaaba8 --- /dev/null +++ b/tests/components/wiim/test_diagnostics.py @@ -0,0 +1,51 @@ +"""Tests for WiiM diagnostics.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion +from wiim.models import WiimDeviceDiagnostics + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_wiim_controller") +async def test_config_entry_diagnostics_redacts_identifiers( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_wiim_device: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics include safe runtime data.""" + mock_wiim_device.as_diagnostics.return_value = WiimDeviceDiagnostics( + name="Test WiiM Device", + udn="uuid:test-udn-1234", + model_name="WiiM Pro", + manufacturer="Linkplay Tech", + firmware_version="4.8.523456", + ip_address="192.168.1.100", + available=True, + supports_http_api=True, + presentation_url_available=True, + event_subscriptions_active=True, + input_modes=("Line In",), + output_modes=("Speaker Out",), + play_mode="Network", + output_mode="speaker", + volume=50, + muted=False, + ) + + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 2f8c8550d87b08..2a74835cfb287c 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -246,68 +246,60 @@ 'routes': list([ ]), 'rssi': None, - 'version': 2, + 'version': 3, 'zha_lib_entities': dict({ 'alarm_control_panel': list([ dict({ - 'info_object': dict({ - 'available': True, - 'class_name': 'AlarmControlPanel', - 'code_arm_required': False, - 'code_format': 'number', - 'device_class': None, - 'device_ieee': '**REDACTED**', - 'enabled': True, - 'endpoint_id': 1, - 'entity_category': None, - 'entity_registry_enabled_default': True, - 'fallback_name': None, - 'group_id': None, - 'migrate_unique_ids': list([ - ]), - 'platform': 'alarm_control_panel', - 'primary': False, - 'state_class': None, - 'supported_features': 15, - 'translation_key': 'alarm_control_panel', - 'translation_placeholders': None, - 'unique_id': '**REDACTED**', - }), - 'state': dict({ - 'available': True, - 'class_name': 'AlarmControlPanel', - 'state': 'disarmed', - }), + 'alarm_state': 'disarmed', + 'available': True, + 'class_name': 'AlarmControlPanel', + 'code_arm_required': False, + 'code_format': 'number', + 'device_class': None, + 'device_ieee': '**REDACTED**', + 'enabled': True, + 'endpoint_id': 1, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'extra_state_attribute_names': list([ + ]), + 'fallback_name': None, + 'group_id': None, + 'migrate_unique_ids': list([ + ]), + 'platform': 'alarm_control_panel', + 'primary': False, + 'state_class': None, + 'supported_features': 15, + 'translation_key': 'alarm_control_panel', + 'translation_placeholders': None, + 'unique_id': '**REDACTED**', }), ]), 'binary_sensor': list([ dict({ - 'info_object': dict({ - 'attribute_name': 'zone_status', - 'available': True, - 'class_name': 'IASZone', - 'device_class': None, - 'device_ieee': '**REDACTED**', - 'enabled': True, - 'endpoint_id': 1, - 'entity_category': None, - 'entity_registry_enabled_default': True, - 'fallback_name': None, - 'group_id': None, - 'migrate_unique_ids': list([ - ]), - 'platform': 'binary_sensor', - 'primary': True, - 'state_class': None, - 'translation_key': 'ias_zone', - 'translation_placeholders': None, - 'unique_id': '**REDACTED**', - }), - 'state': dict({ - 'available': True, - 'class_name': 'IASZone', - 'state': False, - }), + 'attribute_name': 'zone_status', + 'available': True, + 'class_name': 'IASZone', + 'device_class': None, + 'device_ieee': '**REDACTED**', + 'enabled': True, + 'endpoint_id': 1, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'extra_state_attribute_names': list([ + ]), + 'fallback_name': None, + 'group_id': None, + 'is_on': False, + 'migrate_unique_ids': list([ + ]), + 'platform': 'binary_sensor', + 'primary': True, + 'state_class': None, + 'translation_key': 'ias_zone', + 'translation_placeholders': None, + 'unique_id': '**REDACTED**', }), ]), }), diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 331341fdff5b7e..997a050d2f4c0a 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -11,7 +11,7 @@ from zigpy.zcl.clusters import general, homeautomation, hvac, measurement, smartenergy from zigpy.zcl.clusters.hvac import Thermostat -from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor import ATTR_OPTIONS, SensorDeviceClass from homeassistant.components.zha.helpers import get_zha_gateway from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -308,6 +308,8 @@ async def async_test_setpoint_change_source( ) hass_state = hass.states.get(entity_id) assert hass_state.state == "Schedule" + assert hass_state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert hass_state.attributes[ATTR_OPTIONS] == ["Manual", "Schedule", "External"] async def async_test_pi_heating_demand(