diff --git a/.strict-typing b/.strict-typing index 1e8e498f8a189d..43ddeb282dd7f7 100644 --- a/.strict-typing +++ b/.strict-typing @@ -599,6 +599,7 @@ homeassistant.components.vallox.* homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.velux.* +homeassistant.components.victron_gx.* homeassistant.components.vivotek.* homeassistant.components.vlc_telnet.* homeassistant.components.vodafone_station.* diff --git a/homeassistant/components/ai_task/media_source.py b/homeassistant/components/ai_task/media_source.py index 61a212be5b0651..57c06e278f05e7 100644 --- a/homeassistant/components/ai_task/media_source.py +++ b/homeassistant/components/ai_task/media_source.py @@ -25,7 +25,7 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource( hass, DOMAIN, - "AI Generated Images", + "AI generated images", {IMAGE_DIR: str(media_dir)}, f"/{DOMAIN}", ) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 241256fb5ca21a..45a1e0c6e8b84b 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -39,7 +39,6 @@ from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors from .camera import STREAM_SOURCE_LIST from .const import ( - CAMERAS, COMM_RETRIES, COMM_TIMEOUT, DATA_AMCREST, @@ -359,7 +358,7 @@ def _start_event_monitor( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Amcrest IP Camera component.""" - hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []}) + hass.data.setdefault(DATA_AMCREST, {DEVICES: {}}) for device in config[DOMAIN]: name: str = device[CONF_NAME] diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 5c3655e8d3115c..6f244c57f52bdc 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -12,13 +12,11 @@ from aiohttp import web from amcrest import AmcrestError from haffmpeg.camera import CameraMjpeg -import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager -from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, @@ -29,11 +27,13 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( + ATTR_COLOR_BW, CAMERA_WEB_SESSION_TIMEOUT, - CAMERAS, + CBW, COMM_TIMEOUT, DATA_AMCREST, DEVICES, + MOV, RESOLUTION_TO_STREAM, SERVICE_UPDATE, SNAPSHOT_TIMEOUT, @@ -49,65 +49,11 @@ STREAM_SOURCE_LIST = ["snapshot", "mjpeg", "rtsp"] -_ATTR_PTZ_TT = "travel_time" -_ATTR_PTZ_MOV = "movement" -_MOV = [ - "zoom_out", - "zoom_in", - "right", - "left", - "up", - "down", - "right_down", - "right_up", - "left_down", - "left_up", -] _ZOOM_ACTIONS = ["ZoomWide", "ZoomTele"] _MOVE_1_ACTIONS = ["Right", "Left", "Up", "Down"] _MOVE_2_ACTIONS = ["RightDown", "RightUp", "LeftDown", "LeftUp"] _ACTION = _ZOOM_ACTIONS + _MOVE_1_ACTIONS + _MOVE_2_ACTIONS -_DEFAULT_TT = 0.2 - -_ATTR_PRESET = "preset" -_ATTR_COLOR_BW = "color_bw" - -_CBW_COLOR = "color" -_CBW_AUTO = "auto" -_CBW_BW = "bw" -_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW] - -_SRV_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids}) -_SRV_GOTO_SCHEMA = _SRV_SCHEMA.extend( - {vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))} -) -_SRV_CBW_SCHEMA = _SRV_SCHEMA.extend({vol.Required(_ATTR_COLOR_BW): vol.In(_CBW)}) -_SRV_PTZ_SCHEMA = _SRV_SCHEMA.extend( - { - vol.Required(_ATTR_PTZ_MOV): vol.In(_MOV), - vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float, - } -) - -CAMERA_SERVICES = { - "enable_recording": (_SRV_SCHEMA, "async_enable_recording", ()), - "disable_recording": (_SRV_SCHEMA, "async_disable_recording", ()), - "enable_audio": (_SRV_SCHEMA, "async_enable_audio", ()), - "disable_audio": (_SRV_SCHEMA, "async_disable_audio", ()), - "enable_motion_recording": (_SRV_SCHEMA, "async_enable_motion_recording", ()), - "disable_motion_recording": (_SRV_SCHEMA, "async_disable_motion_recording", ()), - "goto_preset": (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)), - "set_color_bw": (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)), - "start_tour": (_SRV_SCHEMA, "async_start_tour", ()), - "stop_tour": (_SRV_SCHEMA, "async_stop_tour", ()), - "ptz_control": ( - _SRV_PTZ_SCHEMA, - "async_ptz_control", - (_ATTR_PTZ_MOV, _ATTR_PTZ_TT), - ), -} - _BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} @@ -275,7 +221,7 @@ def extra_state_attributes(self) -> dict[str, Any]: self._motion_recording_enabled ) if self._color_bw is not None: - attr[_ATTR_COLOR_BW] = self._color_bw + attr[ATTR_COLOR_BW] = self._color_bw return attr @property @@ -322,15 +268,7 @@ def async_on_demand_update(self) -> None: self.async_schedule_update_ha_state(True) async def async_added_to_hass(self) -> None: - """Subscribe to signals and add camera to list.""" - self._unsub_dispatcher.extend( - async_dispatcher_connect( - self.hass, - service_signal(service, self.entity_id), - getattr(self, callback_name), - ) - for service, (_, callback_name, _) in CAMERA_SERVICES.items() - ) + """Subscribe to signals.""" self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, @@ -338,11 +276,9 @@ async def async_added_to_hass(self) -> None: self.async_on_demand_update, ) ) - self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id) async def async_will_remove_from_hass(self) -> None: - """Remove camera from list and disconnect from signals.""" - self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id) + """Disconnect from signals.""" for unsub_dispatcher in self._unsub_dispatcher: unsub_dispatcher() @@ -456,7 +392,7 @@ async def async_stop_tour(self) -> None: async def async_ptz_control(self, movement: str, travel_time: float) -> None: """Move or zoom camera in specified direction.""" - code = _ACTION[_MOV.index(movement)] + code = _ACTION[MOV.index(movement)] kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0} if code in _MOVE_1_ACTIONS: @@ -613,10 +549,10 @@ async def _async_goto_preset(self, preset: int) -> None: ) async def _async_get_color_mode(self) -> str: - return _CBW[await self._api.async_day_night_color] + return CBW[await self._api.async_day_night_color] async def _async_set_color_mode(self, cbw: str) -> None: - await self._api.async_set_day_night_color(_CBW.index(cbw), channel=0) + await self._api.async_set_day_night_color(CBW.index(cbw), channel=0) async def _async_set_color_bw(self, cbw: str) -> None: """Set camera color mode.""" diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py index 377c5642b4b73f..67f37a826a28b7 100644 --- a/homeassistant/components/amcrest/const.py +++ b/homeassistant/components/amcrest/const.py @@ -2,7 +2,6 @@ DOMAIN = "amcrest" DATA_AMCREST = DOMAIN -CAMERAS = "cameras" DEVICES = "devices" BINARY_SENSOR_SCAN_INTERVAL_SECS = 5 @@ -17,3 +16,18 @@ RESOLUTION_LIST = {"high": 0, "low": 1} RESOLUTION_TO_STREAM = {0: "Main", 1: "Extra"} + +ATTR_COLOR_BW = "color_bw" +CBW = ["color", "auto", "bw"] +MOV = [ + "zoom_out", + "zoom_in", + "right", + "left", + "up", + "down", + "right_down", + "right_up", + "left_down", + "left_up", +] diff --git a/homeassistant/components/amcrest/services.py b/homeassistant/components/amcrest/services.py index 6b4ca8ade535f2..8102ed0059497c 100644 --- a/homeassistant/components/amcrest/services.py +++ b/homeassistant/components/amcrest/services.py @@ -1,62 +1,67 @@ -"""Support for Amcrest IP cameras.""" +"""Services for Amcrest IP cameras.""" from __future__ import annotations -from homeassistant.auth.models import User -from homeassistant.auth.permissions.const import POLICY_CONTROL -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import Unauthorized, UnknownUser -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.service import async_extract_entity_ids +import voluptuous as vol -from .camera import CAMERA_SERVICES -from .const import CAMERAS, DATA_AMCREST, DOMAIN -from .helpers import service_signal +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import ATTR_COLOR_BW, CBW, DOMAIN, MOV + +_ATTR_PRESET = "preset" +_ATTR_PTZ_MOV = "movement" +_ATTR_PTZ_TT = "travel_time" +_DEFAULT_TT = 0.2 @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the Amcrest IP Camera services.""" + for service_name, func in ( + ("enable_recording", "async_enable_recording"), + ("disable_recording", "async_disable_recording"), + ("enable_audio", "async_enable_audio"), + ("disable_audio", "async_disable_audio"), + ("enable_motion_recording", "async_enable_motion_recording"), + ("disable_motion_recording", "async_disable_motion_recording"), + ("start_tour", "async_start_tour"), + ("stop_tour", "async_stop_tour"), + ): + service.async_register_platform_entity_service( + hass, + DOMAIN, + service_name, + entity_domain=CAMERA_DOMAIN, + schema=None, + func=func, + ) - def have_permission(user: User | None, entity_id: str) -> bool: - return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL) - - async def async_extract_from_service(call: ServiceCall) -> list[str]: - if call.context.user_id: - user = await hass.auth.async_get_user(call.context.user_id) - if user is None: - raise UnknownUser(context=call.context) - else: - user = None - - if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL: - # Return all entity_ids user has permission to control. - return [ - entity_id - for entity_id in hass.data[DATA_AMCREST][CAMERAS] - if have_permission(user, entity_id) - ] - - if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE: - return [] - - call_ids = await async_extract_entity_ids(call) - entity_ids = [] - for entity_id in hass.data[DATA_AMCREST][CAMERAS]: - if entity_id not in call_ids: - continue - if not have_permission(user, entity_id): - raise Unauthorized( - context=call.context, entity_id=entity_id, permission=POLICY_CONTROL - ) - entity_ids.append(entity_id) - return entity_ids - - async def async_service_handler(call: ServiceCall) -> None: - args = [call.data[arg] for arg in CAMERA_SERVICES[call.service][2]] - for entity_id in await async_extract_from_service(call): - async_dispatcher_send(hass, service_signal(call.service, entity_id), *args) - - for service, params in CAMERA_SERVICES.items(): - hass.services.async_register(DOMAIN, service, async_service_handler, params[0]) + service.async_register_platform_entity_service( + hass, + DOMAIN, + "goto_preset", + entity_domain=CAMERA_DOMAIN, + schema={vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))}, + func="async_goto_preset", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + "set_color_bw", + entity_domain=CAMERA_DOMAIN, + schema={vol.Required(ATTR_COLOR_BW): vol.In(CBW)}, + func="async_set_color_bw", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + "ptz_control", + entity_domain=CAMERA_DOMAIN, + schema={ + vol.Required(_ATTR_PTZ_MOV): vol.In(MOV), + vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float, + }, + func="async_ptz_control", + ) diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index 1668b03b02146a..6b0f1d5f3083cf 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -21,8 +21,9 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.ssl import get_default_context -from .const import DOMAIN +from .const import DOMAIN, MANUFACTURER, BeoModel from .services import async_setup_services +from .util import get_remotes from .websocket import BeoWebsocket @@ -58,15 +59,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool: # Remove casts to str assert entry.unique_id - # Create device now as BeoWebsocket needs a device for debug logging, firing events etc. - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.unique_id)}, - name=entry.title, - model=entry.data[CONF_MODEL], - ) - client = MozartClient(host=entry.data[CONF_HOST], ssl_context=get_default_context()) # Check API and WebSocket connection @@ -83,6 +75,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool: await client.close_api_client() raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error + # Create device now as BeoWebsocket needs a device for debug logging, firing events etc. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.unique_id)}, + model=entry.data[CONF_MODEL], + ) + + # Create devices for paired Beoremote One remotes + for remote in await get_remotes(client): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, f"{remote.serial_number}_{entry.unique_id}")}, + name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{entry.unique_id}", + model=BeoModel.BEOREMOTE_ONE, + serial_number=remote.serial_number, + sw_version=remote.app_version, + manufacturer=MANUFACTURER, + via_device=(DOMAIN, entry.unique_id), + ) + websocket = BeoWebsocket(hass, entry, client) # Add the websocket and API client diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py index 62ee08502e2bf1..2b0dc774f490da 100644 --- a/homeassistant/components/bang_olufsen/config_flow.py +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -52,6 +52,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN): _beolink_jid = "" _client: MozartClient + _friendly_name = "" _host = "" _model = "" _name = "" @@ -111,6 +112,7 @@ async def async_step_user( ) self._beolink_jid = beolink_self.jid + self._friendly_name = beolink_self.friendly_name self._serial_number = get_serial_number_from_jid(beolink_self.jid) await self.async_set_unique_id(self._serial_number) @@ -149,6 +151,7 @@ async def async_step_zeroconf( return self.async_abort(reason="invalid_address") self._model = discovery_info.hostname[:-16].replace("-", " ") + self._friendly_name = discovery_info.properties[ATTR_FRIENDLY_NAME] self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER] self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com" @@ -164,16 +167,13 @@ async def async_step_zeroconf( async def _create_entry(self) -> ConfigFlowResult: """Create the config entry for a discovered or manually configured Bang & Olufsen device.""" - # Ensure that created entities have a unique and easily identifiable id and not a "friendly name" - self._name = f"{self._model}-{self._serial_number}" - return self.async_create_entry( - title=self._name, + title=self._friendly_name, data=EntryData( host=self._host, jid=self._beolink_jid, model=self._model, - name=self._name, + name=self._friendly_name, ), ) diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py index a14e940b65582c..270a51c0c64164 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -20,7 +20,6 @@ CONNECTION_STATUS, DEVICE_BUTTON_EVENTS, DOMAIN, - MANUFACTURER, BeoModel, WebsocketNotification, ) @@ -142,12 +141,6 @@ def __init__( self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}, - name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}", - model=BeoModel.BEOREMOTE_ONE, - serial_number=remote.serial_number, - sw_version=remote.app_version, - manufacturer=MANUFACTURER, - via_device=(DOMAIN, self._unique_id), ) # Make the native key name Home Assistant compatible diff --git a/homeassistant/components/bang_olufsen/sensor.py b/homeassistant/components/bang_olufsen/sensor.py index 9ff703112c391e..04733ea6772b5a 100644 --- a/homeassistant/components/bang_olufsen/sensor.py +++ b/homeassistant/components/bang_olufsen/sensor.py @@ -115,7 +115,7 @@ def __init__(self, config_entry: BeoConfigEntry, remote: PairedRemote) -> None: f"{remote.serial_number}_{self._unique_id}_remote_battery_level" ) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")} + identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}, ) self._attr_native_value = remote.battery_level self._remote = remote diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 6da7ab41aeae12..4b65dca61b597e 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["bsblan"], "quality_scale": "silver", - "requirements": ["python-bsblan==5.1.4"], + "requirements": ["python-bsblan==5.2.0"], "zeroconf": [ { "name": "bsb-lan*", diff --git a/homeassistant/components/fumis/config_flow.py b/homeassistant/components/fumis/config_flow.py index d5916a849fa215..bb1124442ae5ca 100644 --- a/homeassistant/components/fumis/config_flow.py +++ b/homeassistant/components/fumis/config_flow.py @@ -9,6 +9,7 @@ Fumis, FumisAuthenticationError, FumisConnectionError, + FumisInfo, FumisStoveOfflineError, ) import voluptuous as vol @@ -51,23 +52,10 @@ async def async_step_dhcp_confirm( errors: dict[str, str] = {} if user_input is not None: - fumis = Fumis( - mac=self._discovered_mac, - password=user_input[CONF_PIN], - session=async_get_clientsession(self.hass), + errors, info = await self._validate_input( + self._discovered_mac, user_input[CONF_PIN] ) - try: - info = await fumis.update_info() - except FumisAuthenticationError: - errors[CONF_PIN] = "invalid_auth" - except FumisStoveOfflineError: - errors["base"] = "device_offline" - except FumisConnectionError: - errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if info: return self.async_create_entry( title=info.controller.model_name or "Fumis", data={ @@ -96,23 +84,8 @@ async def async_step_user( if user_input is not None: mac = user_input[CONF_MAC].replace(":", "").replace("-", "").upper() - fumis = Fumis( - mac=mac, - password=user_input[CONF_PIN], - session=async_get_clientsession(self.hass), - ) - try: - info = await fumis.update_info() - except FumisAuthenticationError: - errors[CONF_PIN] = "invalid_auth" - except FumisStoveOfflineError: - errors["base"] = "device_offline" - except FumisConnectionError: - errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + errors, info = await self._validate_input(mac, user_input[CONF_PIN]) + if info: await self.async_set_unique_id(format_mac(mac), raise_on_progress=False) self._abort_if_unique_id_configured() return self.async_create_entry( @@ -141,6 +114,35 @@ async def async_step_user( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of a Fumis stove.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + errors, _ = await self._validate_input( + reconfigure_entry.data[CONF_MAC], user_input[CONF_PIN] + ) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_PIN: user_input[CONF_PIN]}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -155,23 +157,10 @@ async def async_step_reauth_confirm( if user_input is not None: reauth_entry = self._get_reauth_entry() - fumis = Fumis( - mac=reauth_entry.data[CONF_MAC], - password=user_input[CONF_PIN], - session=async_get_clientsession(self.hass), + errors, _ = await self._validate_input( + reauth_entry.data[CONF_MAC], user_input[CONF_PIN] ) - try: - await fumis.update_info() - except FumisAuthenticationError: - errors[CONF_PIN] = "invalid_auth" - except FumisStoveOfflineError: - errors["base"] = "device_offline" - except FumisConnectionError: - errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not errors: return self.async_update_reload_and_abort( reauth_entry, data_updates={CONF_PIN: user_input[CONF_PIN]}, @@ -188,3 +177,28 @@ async def async_step_reauth_confirm( ), errors=errors, ) + + async def _validate_input( + self, mac: str, pin: str + ) -> tuple[dict[str, str], FumisInfo | None]: + """Validate credentials, returning errors and info.""" + errors: dict[str, str] = {} + fumis = Fumis( + mac=mac, + password=pin, + session=async_get_clientsession(self.hass), + ) + try: + info = await fumis.update_info() + except FumisAuthenticationError: + errors[CONF_PIN] = "invalid_auth" + except FumisStoveOfflineError: + errors["base"] = "device_offline" + except FumisConnectionError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return errors, info + return errors, None diff --git a/homeassistant/components/fumis/quality_scale.yaml b/homeassistant/components/fumis/quality_scale.yaml index 47ea341873bd03..9840bea67f4916 100644 --- a/homeassistant/components/fumis/quality_scale.yaml +++ b/homeassistant/components/fumis/quality_scale.yaml @@ -62,7 +62,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: This integration does not raise any repairable issues. diff --git a/homeassistant/components/fumis/strings.json b/homeassistant/components/fumis/strings.json index 8e376937945fb2..91ea585d9d0cde 100644 --- a/homeassistant/components/fumis/strings.json +++ b/homeassistant/components/fumis/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -29,6 +30,15 @@ }, "description": "The PIN code for your stove has changed. Please enter the new PIN code to re-authenticate." }, + "reconfigure": { + "data": { + "pin": "[%key:component::fumis::config::step::user::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::fumis::config::step::user::data_description::pin%]" + }, + "description": "Reconfigure your Fumis pellet stove connection." + }, "user": { "data": { "mac": "MAC address", diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py index f061e2eefae50c..724851abe1304c 100644 --- a/homeassistant/components/homee/lock.py +++ b/homeassistant/components/homee/lock.py @@ -1,11 +1,11 @@ """The Homee lock platform.""" -from typing import Any +from typing import TYPE_CHECKING, Any from pyHomee.const import AttributeChangedBy, AttributeType -from pyHomee.model import HomeeNode +from pyHomee.model import HomeeAttribute, HomeeNode -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -15,6 +15,24 @@ PARALLEL_UPDATES = 0 +LOCK_STATE_UNLOCKED = 0.0 +LOCK_STATE_LOCKED = 1.0 + + +def _determine_lock_state_open(attribute: HomeeAttribute) -> float | None: + """Return the attribute value that momentarily unlatches the lock. + + Different homee-compatible locks encode the "open" (unlatch) command + differently. The Hörmann SmartKey uses a signed range {-1, 0, 1} + where -1 is unlatch; other devices extend above with {0, 1, 2}. + Returns None when the device only supports two states. + """ + if attribute.maximum == 2.0: + return 2.0 + if attribute.minimum == -1.0: + return -1.0 + return None + async def add_lock_entities( config_entry: HomeeConfigEntry, @@ -45,20 +63,53 @@ class HomeeLock(HomeeEntity, LockEntity): _attr_name = None + def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None: + """Initialize the homee lock.""" + super().__init__(attribute, entry) + self._lock_state_open = _determine_lock_state_open(attribute) + if self._lock_state_open is not None: + self._attr_supported_features = LockEntityFeature.OPEN + @property def is_locked(self) -> bool: """Return if lock is locked.""" - return self._attribute.current_value == 1.0 + return self._attribute.current_value == LOCK_STATE_LOCKED + + @property + def is_open(self) -> bool: + """Return if lock is open (unlatched).""" + # Require target_value too, so mid-transition away from "open" resolves + # to is_locking/is_unlocking rather than OPEN (HA state precedence). + return ( + self._lock_state_open is not None + and self._attribute.current_value == self._lock_state_open + and self._attribute.target_value == self._lock_state_open + ) @property def is_locking(self) -> bool: """Return if lock is locking.""" - return self._attribute.target_value > self._attribute.current_value + return ( + self._attribute.target_value == LOCK_STATE_LOCKED + and self._attribute.current_value != LOCK_STATE_LOCKED + ) @property def is_unlocking(self) -> bool: """Return if lock is unlocking.""" - return self._attribute.target_value < self._attribute.current_value + return ( + self._attribute.target_value == LOCK_STATE_UNLOCKED + and self._attribute.current_value != LOCK_STATE_UNLOCKED + ) + + @property + def is_opening(self) -> bool: + """Return if lock is opening (unlatching).""" + return ( + self._lock_state_open is not None + and self._attribute.target_value == self._lock_state_open + and self._attribute.current_value != self._lock_state_open + ) @property def changed_by(self) -> str: @@ -80,8 +131,14 @@ def changed_by(self) -> str: async def async_lock(self, **kwargs: Any) -> None: """Lock specified lock. A code to lock the lock with may be specified.""" - await self.async_set_homee_value(1) + await self.async_set_homee_value(LOCK_STATE_LOCKED) async def async_unlock(self, **kwargs: Any) -> None: """Unlock specified lock. A code to unlock the lock with may be specified.""" - await self.async_set_homee_value(0) + await self.async_set_homee_value(LOCK_STATE_UNLOCKED) + + async def async_open(self, **kwargs: Any) -> None: + """Open (unlatch) the lock.""" + if TYPE_CHECKING: + assert self._lock_state_open is not None + await self.async_set_homee_value(self._lock_state_open) diff --git a/homeassistant/components/hue_ble/manifest.json b/homeassistant/components/hue_ble/manifest.json index 707594fcde177b..fffc31c3e93f99 100644 --- a/homeassistant/components/hue_ble/manifest.json +++ b/homeassistant/components/hue_ble/manifest.json @@ -16,5 +16,5 @@ "iot_class": "local_push", "loggers": ["bleak", "HueBLE"], "quality_scale": "bronze", - "requirements": ["HueBLE==2.1.0"] + "requirements": ["HueBLE==2.2.2"] } diff --git a/homeassistant/components/image_upload/media_source.py b/homeassistant/components/image_upload/media_source.py index d1fc978c27839f..eb0f11630e7d49 100644 --- a/homeassistant/components/image_upload/media_source.py +++ b/homeassistant/components/image_upload/media_source.py @@ -27,7 +27,7 @@ async def async_get_media_source(hass: HomeAssistant) -> ImageUploadMediaSource: class ImageUploadMediaSource(MediaSource): """Provide images as media sources.""" - name: str = "Image Upload" + name: str = "Image upload" def __init__(self, hass: HomeAssistant) -> None: """Initialize ImageMediaSource.""" @@ -79,7 +79,7 @@ async def async_browse_media( identifier=None, media_class=MediaClass.APP, media_content_type="", - title="Image Upload", + title="Image upload", can_play=False, can_expand=True, children_media_class=MediaClass.IMAGE, diff --git a/homeassistant/components/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py index 1f5df2ca194c8a..3cd864f41493b2 100644 --- a/homeassistant/components/monoprice/__init__.py +++ b/homeassistant/components/monoprice/__init__.py @@ -12,13 +12,18 @@ from homeassistant.const import CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import CONF_NOT_FIRST_RUN +from .const import CONF_NOT_FIRST_RUN, DOMAIN +from .services import async_setup_services PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + type MonopriceConfigEntry = ConfigEntry[MonopriceRuntimeData] @@ -30,6 +35,12 @@ class MonopriceRuntimeData: first_run: bool +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: MonopriceConfigEntry) -> bool: """Set up Monoprice 6-Zone Amplifier from a config entry.""" port = entry.data[CONF_PORT] diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 4561f29ba56612..fe3e158e163cf4 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -13,12 +13,11 @@ ) from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MonopriceConfigEntry -from .const import CONF_SOURCES, DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT +from .const import CONF_SOURCES, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -72,39 +71,6 @@ async def async_setup_entry( # only call update before add if it's the first run so we can try to detect zones async_add_entities(entities, config_entry.runtime_data.first_run) - platform = entity_platform.async_get_current_platform() - - def _call_service(entities, service_call): - for entity in entities: - if service_call.service == SERVICE_SNAPSHOT: - entity.snapshot() - elif service_call.service == SERVICE_RESTORE: - entity.restore() - - @service.verify_domain_control(DOMAIN) - async def async_service_handle(service_call: core.ServiceCall) -> None: - """Handle for services.""" - entities = await platform.async_extract_from_service(service_call) - - if not entities: - return - - hass.async_add_executor_job(_call_service, entities, service_call) - - hass.services.async_register( - DOMAIN, - SERVICE_SNAPSHOT, - async_service_handle, - schema=cv.make_entity_service_schema({}), - ) - - hass.services.async_register( - DOMAIN, - SERVICE_RESTORE, - async_service_handle, - schema=cv.make_entity_service_schema({}), - ) - class MonopriceZone(MediaPlayerEntity): """Representation of a Monoprice amplifier zone.""" @@ -180,7 +146,6 @@ def restore(self): """Restore saved state.""" if self._snapshot: self._monoprice.restore_zone(self._snapshot) - self.schedule_update_ha_state(True) def select_source(self, source: str) -> None: """Set input source.""" diff --git a/homeassistant/components/monoprice/services.py b/homeassistant/components/monoprice/services.py new file mode 100644 index 00000000000000..4211c1e759c580 --- /dev/null +++ b/homeassistant/components/monoprice/services.py @@ -0,0 +1,30 @@ +"""Services for the monoprice integration.""" + +from __future__ import annotations + +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import service + +from .const import DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SNAPSHOT, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema=None, + func="snapshot", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_RESTORE, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema=None, + func="restore", + ) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 7244a41e975030..f8aebacd056c77 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -53,6 +53,7 @@ Platform.SIREN.value: vol.All(cv.ensure_list, [dict]), Platform.SWITCH.value: vol.All(cv.ensure_list, [dict]), Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), + Platform.TIME.value: vol.All(cv.ensure_list, [dict]), Platform.UPDATE.value: vol.All(cv.ensure_list, [dict]), Platform.VACUUM.value: vol.All(cv.ensure_list, [dict]), Platform.VALVE.value: vol.All(cv.ensure_list, [dict]), diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 57d335685ebf97..7f01c2f745f84b 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -417,6 +417,7 @@ Platform.SIREN, Platform.SWITCH, Platform.TEXT, + Platform.TIME, Platform.UPDATE, Platform.VACUUM, Platform.VALVE, @@ -450,6 +451,7 @@ "switch", "tag", "text", + "time", "update", "vacuum", "valve", diff --git a/homeassistant/components/mqtt/time.py b/homeassistant/components/mqtt/time.py new file mode 100644 index 00000000000000..86de4cea8cf976 --- /dev/null +++ b/homeassistant/components/mqtt/time.py @@ -0,0 +1,156 @@ +"""Support for MQTT time platform.""" + +from __future__ import annotations + +from collections.abc import Callable +import datetime +import logging +from typing import Any + +from dateutil.parser import ParserError, parse +import voluptuous as vol + +from homeassistant.components import time +from homeassistant.components.time import TimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.service_info.mqtt import ReceivePayloadType +from homeassistant.helpers.typing import ConfigType, VolSchemaType + +from . import subscription +from .config import MQTT_RW_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_STATE_TOPIC, + PAYLOAD_NONE, +) +from .entity import MqttEntity, async_setup_entity_entry_helper +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + PublishPayloadType, + ReceiveMessage, +) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +DEFAULT_NAME = "MQTT Time" + +MQTT_TIME_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() + + +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MQTT time through YAML and through MQTT discovery.""" + async_setup_entity_entry_helper( + hass, + config_entry, + MqttTimeEntity, + time.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttTimeEntity(MqttEntity, TimeEntity): + """Representation of the MQTT time entity.""" + + _attr_native_value: datetime.time | None = None + _attributes_extra_blocked = MQTT_TIME_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format = time.ENTITY_ID_FORMAT + + _optimistic: bool + _command_template: Callable[ + [PublishPayloadType, dict[str, Any]], PublishPayloadType + ] + _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] + + @staticmethod + def config_schema() -> VolSchemaType: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), + entity=self, + ).async_render + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + ).async_render_with_possible_json_value + optimistic: bool = config[CONF_OPTIMISTIC] + self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None + self._attr_assumed_state = bool(self._optimistic) + + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = str(self._value_template(msg.payload)) + if payload == PAYLOAD_NONE: + self._attr_native_value = None + return + if payload == "": + _LOGGER.debug( + "Ignoring empty state payload on topic %s for entity %s", + msg.topic, + self.entity_id, + ) + return + try: + value = parse(payload) + except ParserError: + _LOGGER.warning( + "Invalid received time expression on topic %s for entity %s, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + else: + self._attr_native_value = value.time() + + @callback + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + self.add_subscription( + CONF_STATE_TOPIC, + self._handle_state_message_received, + {"_attr_native_value"}, + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) + + async def async_set_value(self, value: datetime.time) -> None: + """Change the time.""" + payload = self._command_template(value.isoformat(), {"value": value}) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) + if self._optimistic: + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py new file mode 100644 index 00000000000000..c6a7bebd805a8e --- /dev/null +++ b/homeassistant/components/overkiz/cover.py @@ -0,0 +1,641 @@ +"""Support for Overkiz covers - shutters etc.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from pyoverkiz.enums import ( + OverkizCommand, + OverkizCommandParam, + OverkizState, + UIClass, + UIWidget, +) +from pyoverkiz.types import StateType as OverkizStateType + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityDescription, + CoverEntityFeature, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OverkizDataConfigEntry +from .const import LOGGER +from .coordinator import OverkizDataUpdateCoordinator +from .entity import OverkizDescriptiveEntity + +# Special position values reported by some Overkiz devices +_POSITION_MY = 108 # "My position" preset +_POSITION_UNKNOWN = 124 # "Unknown position" preset + + +@dataclass(frozen=True, kw_only=True) +class OverkizCoverDescription(CoverEntityDescription): + """Class to describe an Overkiz cover.""" + + open_command: OverkizCommand | None = None + close_command: OverkizCommand | None = None + stop_command: OverkizCommand | None = None + current_position_state: OverkizState | None = None + invert_position: bool = True + set_position_command: OverkizCommand | None = None + is_closed_state: OverkizState | None = None + current_tilt_position_state: OverkizState | None = None + invert_tilt_position: bool = True + set_tilt_position_command: OverkizCommand | None = None + open_tilt_command: OverkizCommand | None = None + open_tilt_command_args: tuple[OverkizStateType, ...] = () + close_tilt_command: OverkizCommand | None = None + close_tilt_command_args: tuple[OverkizStateType, ...] = () + stop_tilt_command: OverkizCommand | None = None + + +COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [ + ## + ## Overrides via UIWidget + ## + # Needs override to support position (and remove support for tilt position which is not supported by this device) + # uiClass is Pergola + OverkizCoverDescription( + key=UIWidget.PERGOLA_HORIZONTAL_AWNING, + device_class=CoverDeviceClass.AWNING, + current_position_state=OverkizState.CORE_DEPLOYMENT, + set_position_command=OverkizCommand.SET_DEPLOYMENT, + open_command=OverkizCommand.DEPLOY, + close_command=OverkizCommand.UNDEPLOY, + invert_position=False, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + ), + OverkizCoverDescription( + key=UIWidget.PERGOLA_HORIZONTAL_AWNING_UNO, + device_class=CoverDeviceClass.AWNING, + current_position_state=OverkizState.CORE_DEPLOYMENT, + set_position_command=OverkizCommand.SET_DEPLOYMENT, + open_command=OverkizCommand.DEPLOY, + close_command=OverkizCommand.UNDEPLOY, + invert_position=False, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + ), + # Needs override to support lower/upper position control + # uiClass is RollerShutter + OverkizCoverDescription( + key=UIWidget.POSITIONABLE_DUAL_ROLLER_SHUTTER, + device_class=CoverDeviceClass.SHUTTER, + current_position_state=OverkizState.CORE_UPPER_CLOSURE, + set_position_command=OverkizCommand.SET_UPPER_CLOSURE, + open_command=OverkizCommand.UPPER_OPEN, + close_command=OverkizCommand.UPPER_CLOSE, + stop_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_UPPER_OPEN_CLOSED, + # Lower position used as tilt (no separate tilt state) + current_tilt_position_state=OverkizState.CORE_LOWER_CLOSURE, + set_tilt_position_command=OverkizCommand.SET_LOWER_CLOSURE, + open_tilt_command=OverkizCommand.LOWER_OPEN, + close_tilt_command=OverkizCommand.LOWER_CLOSE, + stop_tilt_command=OverkizCommand.STOP, + ), + # Needs override to remove open/close commands + # uiClass is VenetianBlind + OverkizCoverDescription( + key=UIWidget.TILT_ONLY_VENETIAN_BLIND, + device_class=CoverDeviceClass.BLIND, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + open_tilt_command=OverkizCommand.TILT_POSITIVE, + close_tilt_command=OverkizCommand.TILT_NEGATIVE, + stop_tilt_command=OverkizCommand.STOP, + ), + # Needs override to support very specific tilt commands (rts:ExteriorVenetianBlindRTSComponent) + # uiClass is ExteriorVenetianBlind + OverkizCoverDescription( + key=UIWidget.UP_DOWN_EXTERIOR_VENETIAN_BLIND, + device_class=CoverDeviceClass.BLIND, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + open_tilt_command=OverkizCommand.TILT_POSITIVE, + open_tilt_command_args=(15, 1), # position (1-127), speed (1-15) + close_tilt_command=OverkizCommand.TILT_NEGATIVE, + close_tilt_command_args=(15, 1), # position (1-127), speed (1-15) + stop_tilt_command=OverkizCommand.STOP, + ), + # Needs override to support this Generic device (rts:GenericRTSComponent) + # uiClass is Generic (not mapped to cover as this is a Generic device class) + OverkizCoverDescription( + key=UIWidget.RTS_GENERIC, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + ), + ## + ## Default cover behavior (via UIClass) + ## + OverkizCoverDescription( + key=UIClass.ADJUSTABLE_SLATS_ROLLER_SHUTTER, + device_class=CoverDeviceClass.BLIND, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + stop_tilt_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.AWNING, + device_class=CoverDeviceClass.AWNING, + current_position_state=OverkizState.CORE_DEPLOYMENT, + set_position_command=OverkizCommand.SET_DEPLOYMENT, + open_command=OverkizCommand.DEPLOY, + close_command=OverkizCommand.UNDEPLOY, + invert_position=False, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.CURTAIN, + device_class=CoverDeviceClass.CURTAIN, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.EXTERIOR_SCREEN, + device_class=CoverDeviceClass.BLIND, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.EXTERIOR_VENETIAN_BLIND, + device_class=CoverDeviceClass.BLIND, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + open_tilt_command=OverkizCommand.TILT_DOWN, + close_tilt_command=OverkizCommand.TILT_UP, + stop_tilt_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.GARAGE_DOOR, + device_class=CoverDeviceClass.GARAGE, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.GATE, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.PERGOLA, + device_class=CoverDeviceClass.AWNING, + is_closed_state=OverkizState.CORE_SLATS_OPEN_CLOSED, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + open_tilt_command=OverkizCommand.OPEN_SLATS, + close_tilt_command=OverkizCommand.CLOSE_SLATS, + stop_tilt_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.ROLLER_SHUTTER, + device_class=CoverDeviceClass.SHUTTER, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.SCREEN, + device_class=CoverDeviceClass.BLIND, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.SHUTTER, + device_class=CoverDeviceClass.SHUTTER, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.SWINGING_SHUTTER, + device_class=CoverDeviceClass.SHUTTER, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.VENETIAN_BLIND, + device_class=CoverDeviceClass.BLIND, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + open_tilt_command=OverkizCommand.TILT_UP, + close_tilt_command=OverkizCommand.TILT_DOWN, + stop_tilt_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.WINDOW, + device_class=CoverDeviceClass.WINDOW, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + stop_command=OverkizCommand.STOP, + ), +] + +SUPPORTED_DEVICES = {description.key: description for description in COVER_DESCRIPTIONS} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OverkizDataConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Overkiz covers from a config entry.""" + data = entry.runtime_data + + entities: list[OverkizCover] = [] + + for device in data.platforms[Platform.COVER]: + if description := ( + SUPPORTED_DEVICES.get(device.widget) + or SUPPORTED_DEVICES.get(device.ui_class) + ): + entities.append( + OverkizCover(device.device_url, data.coordinator, description) + ) + + # Cover platform does not support configuring the speed of the cover + # For covers where the speed can be configured, we create a separate entity + if ( + OverkizCommand.SET_CLOSURE_AND_LINEAR_SPEED + in device.definition.commands + ): + entities.append( + OverkizLowSpeedCover( + device.device_url, data.coordinator, description + ) + ) + + async_add_entities(entities) + + +class OverkizCover(OverkizDescriptiveEntity, CoverEntity): + """Representation of an Overkiz Cover.""" + + entity_description: OverkizCoverDescription + + def __init__( + self, + device_url: str, + coordinator: OverkizDataUpdateCoordinator, + description: OverkizCoverDescription, + ) -> None: + """Initialize the device.""" + super().__init__(device_url, coordinator, description) + + # Use device url as unique ID for backwards compatibility + self._attr_unique_id = self.device.device_url + + # Overkiz does support covers where only tilt commands are supported + # and HA sets by default open/close as supported feature which conflicts + supported_features = CoverEntityFeature(0) + + if self.entity_description.open_command and self.executor.has_command( + self.entity_description.open_command + ): + supported_features |= CoverEntityFeature.OPEN + + if self.entity_description.stop_command and self.executor.has_command( + self.entity_description.stop_command + ): + supported_features |= CoverEntityFeature.STOP + + if self.entity_description.close_command and self.executor.has_command( + self.entity_description.close_command + ): + supported_features |= CoverEntityFeature.CLOSE + + if self.entity_description.open_tilt_command and self.executor.has_command( + self.entity_description.open_tilt_command + ): + supported_features |= CoverEntityFeature.OPEN_TILT + + if self.entity_description.stop_tilt_command and self.executor.has_command( + self.entity_description.stop_tilt_command + ): + supported_features |= CoverEntityFeature.STOP_TILT + + if self.entity_description.close_tilt_command and self.executor.has_command( + self.entity_description.close_tilt_command + ): + supported_features |= CoverEntityFeature.CLOSE_TILT + + if ( + self.entity_description.set_tilt_position_command + and self.executor.has_command( + self.entity_description.set_tilt_position_command + ) + ): + supported_features |= CoverEntityFeature.SET_TILT_POSITION + + if self.entity_description.set_position_command and self.executor.has_command( + self.entity_description.set_position_command + ): + supported_features |= CoverEntityFeature.SET_POSITION + + self._attr_supported_features = supported_features + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + if is_closed_state := self.entity_description.is_closed_state: + if state := self.device.states.get(is_closed_state): + return state.value == OverkizCommandParam.CLOSED + + if (position := self.current_cover_position) is not None: + return position == 0 + + if (tilt_position := self.current_cover_tilt_position) is not None: + return tilt_position == 0 + + return None + + @property + def current_cover_position(self) -> int | None: + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + state_name = self.entity_description.current_position_state + + if not state_name or not (state := self.device.states[state_name]): + return None + + position = state.value_as_int + + # Fallback for "My position" preset + if position == _POSITION_MY: + LOGGER.debug( + "Overkiz cover position is invalid (%s). Device: %s, State: %s", + _POSITION_MY, + self.device.device_url, + state_name, + ) + + if fallback_state := self.device.states[ + OverkizState.CORE_MEMORIZED_1_POSITION + ]: + position = fallback_state.value_as_int + else: + return None + + # Fallback for "Unknown position" preset + if position == _POSITION_UNKNOWN: + LOGGER.debug( + "Overkiz cover position is invalid (%s). Device: %s, State: %s", + _POSITION_UNKNOWN, + self.device.device_url, + state_name, + ) + + if fallback_state := self.device.states[OverkizState.CORE_TARGET_CLOSURE]: + position = fallback_state.value_as_int + else: + return None + + if position is None: + return None + + # Invert position if needed (some devices report 0 as open and 100 as closed) + if self.entity_description.invert_position: + position = 100 - position + + return position + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + if self.entity_description.invert_position: + position = 100 - position + + if command := self.entity_description.set_position_command: + await self.executor.async_execute_command(command, position) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + if command := self.entity_description.open_command: + await self.executor.async_execute_command(command) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + if command := self.entity_description.close_command: + await self.executor.async_execute_command(command) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + if command := self.entity_description.stop_command: + await self.executor.async_execute_command(command) + + @property + def current_cover_tilt_position(self) -> int | None: + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + state_name = self.entity_description.current_tilt_position_state + + if state_name and (state := self.device.states[state_name]): + position = state.value_as_int + if position is None: + return None + + if self.entity_description.invert_tilt_position: + position = 100 - position + + return position + + return None + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + position = kwargs[ATTR_TILT_POSITION] + + if self.entity_description.invert_tilt_position: + position = 100 - position + + if command := self.entity_description.set_tilt_position_command: + await self.executor.async_execute_command(command, position) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + if command := self.entity_description.open_tilt_command: + await self.executor.async_execute_command( + command, *self.entity_description.open_tilt_command_args + ) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + if command := self.entity_description.close_tilt_command: + await self.executor.async_execute_command( + command, *self.entity_description.close_tilt_command_args + ) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + if command := self.entity_description.stop_tilt_command: + await self.executor.async_execute_command(command) + + @property + def is_opening(self) -> bool | None: + """Return if the cover is opening or not.""" + # Check if any open() commands are currently running for this device + if (command := self.entity_description.open_command) and self.is_running( + command + ): + return True + + # Check if any open_tilt() commands are currently running for this device + if (command := self.entity_description.open_tilt_command) and self.is_running( + command + ): + return True + + if self.moving_offset is None: + return None + + # Check if the cover is moving in a direction consistent with opening + if self.entity_description.invert_position: + return self.moving_offset > 0 + return self.moving_offset < 0 + + @property + def is_closing(self) -> bool | None: + """Return if the cover is closing or not.""" + # Check if any close() commands are currently running for this device + if (command := self.entity_description.close_command) and self.is_running( + command + ): + return True + + # Check if any close_tilt() commands are currently running for this device + if (command := self.entity_description.close_tilt_command) and self.is_running( + command + ): + return True + + if self.moving_offset is None: + return None + + # Check if the cover is moving in a direction consistent with closing + if self.entity_description.invert_position: + return self.moving_offset < 0 + return self.moving_offset > 0 + + def is_running(self, command: OverkizCommand) -> bool: + """Return if the given commands are currently running.""" + return any( + execution.get("device_url") == self.device.device_url + and execution.get("command_name") == command + for execution in self.coordinator.executions.values() + ) + + @property + def moving_offset(self) -> int | None: + """Return the offset between the targeted position and the current one if the cover is moving.""" + moving_state = self.device.states.get(OverkizState.CORE_MOVING) + if moving_state is None or moving_state.value_as_bool is not True: + return None + + current_closure = self.device.states.get( + self.entity_description.current_position_state or OverkizState.CORE_CLOSURE + ) + target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) + + if not current_closure or not target_closure: + return None + + current_value = current_closure.value_as_int + target_value = target_closure.value_as_int + + if current_value is None or target_value is None: + return None + + return current_value - target_value + + +class OverkizLowSpeedCover(OverkizCover): + """Representation of an Overkiz Low Speed cover.""" + + def __init__( + self, + device_url: str, + coordinator: OverkizDataUpdateCoordinator, + description: OverkizCoverDescription, + ) -> None: + """Initialize the device.""" + super().__init__(device_url, coordinator, description) + + self._attr_name = "Low speed" + self._attr_unique_id = f"{self._attr_unique_id}_low_speed" + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + await self._async_set_cover_position_low_speed(kwargs[ATTR_POSITION]) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._async_set_cover_position_low_speed(100) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self._async_set_cover_position_low_speed(0) + + async def _async_set_cover_position_low_speed(self, position: int) -> None: + """Move the cover to a specific position with a low speed.""" + await self.executor.async_execute_command( + OverkizCommand.SET_CLOSURE_AND_LINEAR_SPEED, + 100 - position, + OverkizCommandParam.LOWSPEED, + ) diff --git a/homeassistant/components/overkiz/cover/__init__.py b/homeassistant/components/overkiz/cover/__init__.py deleted file mode 100644 index dd3216f9c1095e..00000000000000 --- a/homeassistant/components/overkiz/cover/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Support for Overkiz covers - shutters etc.""" - -from pyoverkiz.enums import OverkizCommand, UIClass - -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .. import OverkizDataConfigEntry -from .awning import Awning -from .generic_cover import OverkizGenericCover -from .vertical_cover import LowSpeedCover, VerticalCover - - -async def async_setup_entry( - hass: HomeAssistant, - entry: OverkizDataConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the Overkiz covers from a config entry.""" - data = entry.runtime_data - - entities: list[OverkizGenericCover] = [ - Awning(device.device_url, data.coordinator) - for device in data.platforms[Platform.COVER] - if device.ui_class == UIClass.AWNING - ] - - entities += [ - VerticalCover(device.device_url, data.coordinator) - for device in data.platforms[Platform.COVER] - if device.ui_class != UIClass.AWNING - ] - - entities += [ - LowSpeedCover(device.device_url, data.coordinator) - for device in data.platforms[Platform.COVER] - if OverkizCommand.SET_CLOSURE_AND_LINEAR_SPEED in device.definition.commands - ] - - async_add_entities(entities) diff --git a/homeassistant/components/overkiz/cover/awning.py b/homeassistant/components/overkiz/cover/awning.py deleted file mode 100644 index 4b6e5b176a7577..00000000000000 --- a/homeassistant/components/overkiz/cover/awning.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Support for Overkiz awnings.""" - -from __future__ import annotations - -from typing import Any, cast - -from pyoverkiz.enums import OverkizCommand, OverkizState - -from homeassistant.components.cover import ( - ATTR_POSITION, - CoverDeviceClass, - CoverEntityFeature, -) - -from .generic_cover import ( - COMMANDS_CLOSE, - COMMANDS_OPEN, - COMMANDS_STOP, - OverkizGenericCover, -) - - -class Awning(OverkizGenericCover): - """Representation of an Overkiz awning.""" - - _attr_device_class = CoverDeviceClass.AWNING - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = super().supported_features - - if self.executor.has_command(OverkizCommand.SET_DEPLOYMENT): - supported_features |= CoverEntityFeature.SET_POSITION - - if self.executor.has_command(OverkizCommand.DEPLOY): - supported_features |= CoverEntityFeature.OPEN - - if self.executor.has_command(*COMMANDS_STOP): - supported_features |= CoverEntityFeature.STOP - - if self.executor.has_command(OverkizCommand.UNDEPLOY): - supported_features |= CoverEntityFeature.CLOSE - - return supported_features - - @property - def current_cover_position(self) -> int | None: - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - current_position = self.executor.select_state(OverkizState.CORE_DEPLOYMENT) - if current_position is not None: - return cast(int, current_position) - - return None - - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" - await self.executor.async_execute_command( - OverkizCommand.SET_DEPLOYMENT, kwargs[ATTR_POSITION] - ) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open the cover.""" - await self.executor.async_execute_command(OverkizCommand.DEPLOY) - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close the cover.""" - await self.executor.async_execute_command(OverkizCommand.UNDEPLOY) - - @property - def is_opening(self) -> bool | None: - """Return if the cover is opening or not.""" - if self.is_running(COMMANDS_OPEN): - return True - - # Check if cover is moving based on current state - is_moving = self.device.states.get(OverkizState.CORE_MOVING) - current_closure = self.device.states.get(OverkizState.CORE_DEPLOYMENT) - target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) - - if not is_moving or not current_closure or not target_closure: - return None - - return cast(int, current_closure.value) < cast(int, target_closure.value) - - @property - def is_closing(self) -> bool | None: - """Return if the cover is closing or not.""" - if self.is_running(COMMANDS_CLOSE): - return True - - # Check if cover is moving based on current state - is_moving = self.device.states.get(OverkizState.CORE_MOVING) - current_closure = self.device.states.get(OverkizState.CORE_DEPLOYMENT) - target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) - - if not is_moving or not current_closure or not target_closure: - return None - - return cast(int, current_closure.value) > cast(int, target_closure.value) diff --git a/homeassistant/components/overkiz/cover/generic_cover.py b/homeassistant/components/overkiz/cover/generic_cover.py deleted file mode 100644 index df13072524d064..00000000000000 --- a/homeassistant/components/overkiz/cover/generic_cover.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Base class for Overkiz covers, shutters, awnings, etc.""" - -from __future__ import annotations - -from typing import Any, cast - -from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState - -from homeassistant.components.cover import ( - ATTR_TILT_POSITION, - CoverEntity, - CoverEntityFeature, -) - -from ..entity import OverkizEntity - -ATTR_OBSTRUCTION_DETECTED = "obstruction-detected" - -COMMANDS_STOP: list[OverkizCommand] = [ - OverkizCommand.STOP, - OverkizCommand.MY, -] -COMMANDS_STOP_TILT: list[OverkizCommand] = [ - OverkizCommand.STOP, - OverkizCommand.MY, -] -COMMANDS_OPEN: list[OverkizCommand] = [ - OverkizCommand.OPEN, - OverkizCommand.UP, -] -COMMANDS_OPEN_TILT: list[OverkizCommand] = [ - OverkizCommand.OPEN_SLATS, - OverkizCommand.TILT_DOWN, -] -COMMANDS_CLOSE: list[OverkizCommand] = [ - OverkizCommand.CLOSE, - OverkizCommand.DOWN, -] -COMMANDS_CLOSE_TILT: list[OverkizCommand] = [ - OverkizCommand.CLOSE_SLATS, - OverkizCommand.TILT_UP, -] - -COMMANDS_SET_TILT_POSITION: list[OverkizCommand] = [OverkizCommand.SET_ORIENTATION] - - -class OverkizGenericCover(OverkizEntity, CoverEntity): - """Representation of an Overkiz Cover.""" - - @property - def current_cover_tilt_position(self) -> int | None: - """Return current position of cover tilt. - - None is unknown, 0 is closed, 100 is fully open. - """ - position = self.executor.select_state( - OverkizState.CORE_SLATS_ORIENTATION, OverkizState.CORE_SLATE_ORIENTATION - ) - if position is not None: - return 100 - cast(int, position) - - return None - - async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the cover tilt to a specific position.""" - if command := self.executor.select_command(*COMMANDS_SET_TILT_POSITION): - await self.executor.async_execute_command( - command, - 100 - kwargs[ATTR_TILT_POSITION], - ) - - @property - def is_closed(self) -> bool | None: - """Return if the cover is closed.""" - - state = self.executor.select_state( - OverkizState.CORE_OPEN_CLOSED, - OverkizState.CORE_SLATS_OPEN_CLOSED, - OverkizState.CORE_OPEN_CLOSED_PARTIAL, - OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, - OverkizState.CORE_OPEN_CLOSED_UNKNOWN, - OverkizState.MYFOX_SHUTTER_STATUS, - ) - if state is not None: - return state == OverkizCommandParam.CLOSED - - # Keep this condition after the previous one. Some device like the pedestrian gate, always return 50 as position. - if self.current_cover_position is not None: - return self.current_cover_position == 0 - - if self.current_cover_tilt_position is not None: - return self.current_cover_tilt_position == 0 - - return None - - async def async_open_cover_tilt(self, **kwargs: Any) -> None: - """Open the cover tilt.""" - if command := self.executor.select_command(*COMMANDS_OPEN_TILT): - await self.executor.async_execute_command(command) - - async def async_close_cover_tilt(self, **kwargs: Any) -> None: - """Close the cover tilt.""" - if command := self.executor.select_command(*COMMANDS_CLOSE_TILT): - await self.executor.async_execute_command(command) - - async def async_stop_cover(self, **kwargs: Any) -> None: - """Stop the cover.""" - if command := self.executor.select_command(*COMMANDS_STOP): - await self.executor.async_execute_command(command) - - async def async_stop_cover_tilt(self, **kwargs: Any) -> None: - """Stop the cover tilt.""" - if command := self.executor.select_command(*COMMANDS_STOP_TILT): - await self.executor.async_execute_command(command) - - def is_running(self, commands: list[OverkizCommand]) -> bool: - """Return if the given commands are currently running.""" - return any( - execution.get("device_url") == self.device.device_url - and execution.get("command_name") in commands - for execution in self.coordinator.executions.values() - ) - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = CoverEntityFeature(0) - - if self.executor.has_command(*COMMANDS_OPEN_TILT): - supported_features |= CoverEntityFeature.OPEN_TILT - - if self.executor.has_command(*COMMANDS_STOP_TILT): - supported_features |= CoverEntityFeature.STOP_TILT - - if self.executor.has_command(*COMMANDS_CLOSE_TILT): - supported_features |= CoverEntityFeature.CLOSE_TILT - - if self.executor.has_command(*COMMANDS_SET_TILT_POSITION): - supported_features |= CoverEntityFeature.SET_TILT_POSITION - - return supported_features diff --git a/homeassistant/components/overkiz/cover/vertical_cover.py b/homeassistant/components/overkiz/cover/vertical_cover.py deleted file mode 100644 index 48ac2c838c5356..00000000000000 --- a/homeassistant/components/overkiz/cover/vertical_cover.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Support for Overkiz Vertical Covers.""" - -from __future__ import annotations - -from typing import Any, cast - -from pyoverkiz.enums import ( - OverkizCommand, - OverkizCommandParam, - OverkizState, - UIClass, - UIWidget, -) - -from homeassistant.components.cover import ( - ATTR_POSITION, - CoverDeviceClass, - CoverEntityFeature, -) - -from ..coordinator import OverkizDataUpdateCoordinator -from .generic_cover import ( - COMMANDS_CLOSE_TILT, - COMMANDS_OPEN_TILT, - COMMANDS_STOP, - OverkizGenericCover, -) - -COMMANDS_OPEN = [OverkizCommand.OPEN, OverkizCommand.UP, OverkizCommand.CYCLE] -COMMANDS_CLOSE = [OverkizCommand.CLOSE, OverkizCommand.DOWN, OverkizCommand.CYCLE] - -OVERKIZ_DEVICE_TO_DEVICE_CLASS = { - UIClass.CURTAIN: CoverDeviceClass.CURTAIN, - UIClass.EXTERIOR_SCREEN: CoverDeviceClass.BLIND, - UIClass.EXTERIOR_VENETIAN_BLIND: CoverDeviceClass.BLIND, - UIClass.GARAGE_DOOR: CoverDeviceClass.GARAGE, - UIClass.GATE: CoverDeviceClass.GATE, - UIWidget.MY_FOX_SECURITY_CAMERA: CoverDeviceClass.SHUTTER, - UIClass.PERGOLA: CoverDeviceClass.AWNING, - UIClass.ROLLER_SHUTTER: CoverDeviceClass.SHUTTER, - UIClass.SWINGING_SHUTTER: CoverDeviceClass.SHUTTER, - UIClass.WINDOW: CoverDeviceClass.WINDOW, -} - - -class VerticalCover(OverkizGenericCover): - """Representation of an Overkiz vertical cover.""" - - def __init__( - self, device_url: str, coordinator: OverkizDataUpdateCoordinator - ) -> None: - """Initialize vertical cover.""" - super().__init__(device_url, coordinator) - self._attr_device_class = ( - OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.widget) - or OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.ui_class) - or CoverDeviceClass.BLIND - ) - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = super().supported_features - - if self.executor.has_command(OverkizCommand.SET_CLOSURE): - supported_features |= CoverEntityFeature.SET_POSITION - - if self.executor.has_command(*COMMANDS_OPEN): - supported_features |= CoverEntityFeature.OPEN - - if self.executor.has_command(*COMMANDS_STOP): - supported_features |= CoverEntityFeature.STOP - - if self.executor.has_command(*COMMANDS_CLOSE): - supported_features |= CoverEntityFeature.CLOSE - - return supported_features - - @property - def current_cover_position(self) -> int | None: - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - position = self.executor.select_state( - OverkizState.CORE_CLOSURE, - OverkizState.CORE_CLOSURE_OR_ROCKER_POSITION, - OverkizState.CORE_PEDESTRIAN_POSITION, - ) - - if position is None: - return None - - return 100 - cast(int, position) - - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" - position = 100 - kwargs[ATTR_POSITION] - await self.executor.async_execute_command(OverkizCommand.SET_CLOSURE, position) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open the cover.""" - if command := self.executor.select_command(*COMMANDS_OPEN): - await self.executor.async_execute_command(command) - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close the cover.""" - if command := self.executor.select_command(*COMMANDS_CLOSE): - await self.executor.async_execute_command(command) - - @property - def is_opening(self) -> bool | None: - """Return if the cover is opening or not.""" - if self.is_running(COMMANDS_OPEN + COMMANDS_OPEN_TILT): - return True - - # Check if cover is moving based on current state - is_moving = self.device.states.get(OverkizState.CORE_MOVING) - current_closure = self.device.states.get(OverkizState.CORE_CLOSURE) - target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) - - if not is_moving or not current_closure or not target_closure: - return None - - return cast(int, current_closure.value) > cast(int, target_closure.value) - - @property - def is_closing(self) -> bool | None: - """Return if the cover is closing or not.""" - if self.is_running(COMMANDS_CLOSE + COMMANDS_CLOSE_TILT): - return True - - # Check if cover is moving based on current state - is_moving = self.device.states.get(OverkizState.CORE_MOVING) - current_closure = self.device.states.get(OverkizState.CORE_CLOSURE) - target_closure = self.device.states.get(OverkizState.CORE_TARGET_CLOSURE) - - if not is_moving or not current_closure or not target_closure: - return None - - return cast(int, current_closure.value) < cast(int, target_closure.value) - - -class LowSpeedCover(VerticalCover): - """Representation of an Overkiz Low Speed cover.""" - - def __init__( - self, - device_url: str, - coordinator: OverkizDataUpdateCoordinator, - ) -> None: - """Initialize the device.""" - super().__init__(device_url, coordinator) - self._attr_name = "Low speed" - self._attr_unique_id = f"{self._attr_unique_id}_low_speed" - - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" - await self.async_set_cover_position_low_speed(**kwargs) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open the cover.""" - await self.async_set_cover_position_low_speed(**{ATTR_POSITION: 100}) - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close the cover.""" - await self.async_set_cover_position_low_speed(**{ATTR_POSITION: 0}) - - async def async_set_cover_position_low_speed(self, **kwargs: Any) -> None: - """Move the cover to a specific position with a low speed.""" - position = 100 - kwargs.get(ATTR_POSITION, 0) - - await self.executor.async_execute_command( - OverkizCommand.SET_CLOSURE_AND_LINEAR_SPEED, - position, - OverkizCommandParam.LOWSPEED, - ) diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py index e7ebc5ad7d1aaf..3c75c00eb2d856 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -44,6 +44,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> boo coordinator_outputs = SatelIntegraOutputsCoordinator(hass, entry, client) coordinator_partitions = SatelIntegraPartitionsCoordinator(hass, entry, client) + for coordinator in ( + coordinator_zones, + coordinator_outputs, + coordinator_partitions, + ): + coordinator.setup() + await client.async_connect( coordinator_zones.zones_update_callback, coordinator_outputs.outputs_update_callback, diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 36258155a51159..c9946e5a00d26b 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -105,13 +105,8 @@ def _handle_coordinator_update(self) -> None: self._attr_alarm_state = self._read_alarm_state() self.async_write_ha_state() - def _read_alarm_state(self) -> AlarmControlPanelState | None: + def _read_alarm_state(self) -> AlarmControlPanelState: """Read current status of the alarm and translate it into HA status.""" - - if not self._controller.connected: - _LOGGER.debug("Alarm panel not connected") - return None - for satel_state, ha_state in ALARM_STATE_MAP.items(): if ( satel_state in self.coordinator.data diff --git a/homeassistant/components/satel_integra/coordinator.py b/homeassistant/components/satel_integra/coordinator.py index 19101ba3ec43b7..8d077753a905d6 100644 --- a/homeassistant/components/satel_integra/coordinator.py +++ b/homeassistant/components/satel_integra/coordinator.py @@ -50,6 +50,17 @@ def __init__( name=f"{entry.entry_id} {self.__class__.__name__}", ) + def setup(self) -> None: + """Set up client callbacks for this coordinator.""" + self.client.controller.add_connection_status_callback( + self._async_handle_connection_state_update + ) + + @callback + def _async_handle_connection_state_update(self) -> None: + """Notify listeners on connection state changes from the client.""" + self.async_update_listeners() + class SatelIntegraZonesCoordinator(SatelIntegraBaseCoordinator[dict[int, bool]]): """DataUpdateCoordinator to handle zone updates.""" diff --git a/homeassistant/components/satel_integra/entity.py b/homeassistant/components/satel_integra/entity.py index ac8e391aa9608b..041404289aaaae 100644 --- a/homeassistant/components/satel_integra/entity.py +++ b/homeassistant/components/satel_integra/entity.py @@ -65,3 +65,8 @@ def __init__( identifiers={(DOMAIN, self._attr_unique_id)}, via_device=(DOMAIN, config_entry_id), ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._controller.connected diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 33c33ccca6760f..ccb66f3cc9607d 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -1,13 +1,10 @@ """Support to manage a shopping list.""" -# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations -from collections.abc import Callable from http import HTTPStatus import logging -from typing import Any, cast -import uuid +from typing import Any from aiohttp import web import voluptuous as vol @@ -15,19 +12,21 @@ from homeassistant import config_entries from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import Context, HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import JsonValueType, load_json_array +from .common import ( + NoMatchingShoppingListItem, + ShoppingData, + ShoppingListConfigEntry, + _get_shopping_data, +) from .const import ( ATTR_REVERSE, DEFAULT_REVERSE, DOMAIN, - EVENT_SHOPPING_LIST_UPDATED, SERVICE_ADD_ITEM, SERVICE_CLEAR_COMPLETED_ITEMS, SERVICE_COMPLETE_ALL, @@ -40,12 +39,9 @@ PLATFORMS = [Platform.TODO] -ATTR_COMPLETE = "complete" - _LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) -ITEM_UPDATE_SCHEMA = vol.Schema({ATTR_COMPLETE: bool, ATTR_NAME: str}) -PERSISTENCE = ".shopping_list.json" SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) SERVICE_LIST_SCHEMA = vol.Schema({}) @@ -69,17 +65,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: ShoppingListConfigEntry +) -> bool: """Set up shopping list from config flow.""" async def add_item_service(call: ServiceCall) -> None: """Add an item with `name`.""" - data = hass.data[DOMAIN] - await data.async_add(call.data[ATTR_NAME]) + await config_entry.runtime_data.async_add(call.data[ATTR_NAME]) async def remove_item_service(call: ServiceCall) -> None: """Remove the first item with matching `name`.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data name = call.data[ATTR_NAME] try: @@ -87,20 +84,19 @@ async def remove_item_service(call: ServiceCall) -> None: except IndexError: _LOGGER.error("Removing of item failed: %s cannot be found", name) else: - await data.async_remove(item["id"]) + await data.async_remove(str(item["id"])) async def complete_item_service(call: ServiceCall) -> None: """Mark the first item with matching `name` as completed.""" - data = hass.data[DOMAIN] name = call.data[ATTR_NAME] try: - await data.async_complete(name) + await config_entry.runtime_data.async_complete(name) except NoMatchingShoppingListItem: _LOGGER.error("Completing of item failed: %s cannot be found", name) async def incomplete_item_service(call: ServiceCall) -> None: """Mark the first item with matching `name` as incomplete.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data name = call.data[ATTR_NAME] try: @@ -108,7 +104,7 @@ async def incomplete_item_service(call: ServiceCall) -> None: except IndexError: _LOGGER.error("Restoring of item failed: %s cannot be found", name) else: - await data.async_update(item["id"], {"name": name, "complete": False}) + await data.async_update(str(item["id"]), {"name": name, "complete": False}) async def complete_all_service(call: ServiceCall) -> None: """Mark all items in the list as complete.""" @@ -126,7 +122,7 @@ async def sort_list_service(call: ServiceCall) -> None: """Sort all items by name.""" await data.async_sort(call.data[ATTR_REVERSE]) - data = hass.data[DOMAIN] = ShoppingData(hass) + data = config_entry.runtime_data = ShoppingData(hass) await data.async_load() hass.services.async_register( @@ -186,247 +182,6 @@ async def sort_list_service(call: ServiceCall) -> None: return True -class NoMatchingShoppingListItem(Exception): - """No matching item could be found in the shopping list.""" - - -class ShoppingData: - """Class to hold shopping list data.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the shopping list.""" - self.hass = hass - self.items: list[dict[str, JsonValueType]] = [] - self._listeners: list[Callable[[], None]] = [] - - async def async_add( - self, name: str | None, complete: bool = False, context: Context | None = None - ) -> dict[str, JsonValueType]: - """Add a shopping list item.""" - item: dict[str, JsonValueType] = { - "name": name, - "id": uuid.uuid4().hex, - "complete": complete, - } - self.items.append(item) - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "add", "item": item}, - context=context, - ) - return item - - async def async_remove( - self, item_id: str, context: Context | None = None - ) -> dict[str, JsonValueType] | None: - """Remove a shopping list item.""" - removed = await self.async_remove_items( - item_ids=set({item_id}), context=context - ) - return next(iter(removed), None) - - async def async_remove_items( - self, item_ids: set[str], context: Context | None = None - ) -> list[dict[str, JsonValueType]]: - """Remove a shopping list item.""" - items_dict: dict[str, dict[str, JsonValueType]] = {} - for itm in self.items: - item_id = cast(str, itm["id"]) - items_dict[item_id] = itm - removed = [] - for item_id in item_ids: - _LOGGER.debug( - "Removing %s", - ) - if not (item := items_dict.pop(item_id, None)): - raise NoMatchingShoppingListItem( - "Item '{item_id}' not found in shopping list" - ) - removed.append(item) - self.items = list(items_dict.values()) - await self.hass.async_add_executor_job(self.save) - self._async_notify() - for item in removed: - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "remove", "item": item}, - context=context, - ) - return removed - - async def async_complete( - self, name: str, context: Context | None = None - ) -> list[dict[str, JsonValueType]]: - """Mark all shopping list items with the given name as complete.""" - complete_items = [ - item for item in self.items if item["name"] == name and not item["complete"] - ] - - if len(complete_items) == 0: - raise NoMatchingShoppingListItem - - for item in complete_items: - _LOGGER.debug("Completing %s", item) - item["complete"] = True - await self.hass.async_add_executor_job(self.save) - self._async_notify() - for item in complete_items: - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "complete", "item": item}, - context=context, - ) - return complete_items - - async def async_update( - self, item_id: str | None, info: dict[str, Any], context: Context | None = None - ) -> dict[str, JsonValueType]: - """Update a shopping list item.""" - item = next((itm for itm in self.items if itm["id"] == item_id), None) - - if item is None: - raise NoMatchingShoppingListItem - - info = ITEM_UPDATE_SCHEMA(info) - item.update(info) - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "update", "item": item}, - context=context, - ) - return item - - async def async_clear_completed(self, context: Context | None = None) -> None: - """Clear completed items.""" - self.items = [itm for itm in self.items if not itm["complete"]] - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "clear"}, - context=context, - ) - - async def async_update_list( - self, info: dict[str, JsonValueType], context: Context | None = None - ) -> list[dict[str, JsonValueType]]: - """Update all items in the list.""" - for item in self.items: - item.update(info) - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "update_list"}, - context=context, - ) - return self.items - - async def async_reorder( - self, item_ids: list[str], context: Context | None = None - ) -> None: - """Reorder items.""" - # The array for sorted items. - new_items = [] - all_items_mapping = {item["id"]: item for item in self.items} - # Append items by the order of passed in array. - for item_id in item_ids: - if item_id not in all_items_mapping: - raise NoMatchingShoppingListItem - new_items.append(all_items_mapping[item_id]) - # Remove the item from mapping after it's appended in the result array. - del all_items_mapping[item_id] - # Append the rest of the items - for value in all_items_mapping.values(): - # All the unchecked items must be passed in the item_ids array, - # so all items left in the mapping should be checked items. - if value["complete"] is False: - raise vol.Invalid( - "The item ids array doesn't contain all the unchecked shopping list" - " items." - ) - new_items.append(value) - self.items = new_items - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "reorder"}, - context=context, - ) - - async def async_move_item(self, uid: str, previous: str | None = None) -> None: - """Re-order a shopping list item.""" - if uid == previous: - return - item_idx = {cast(str, itm["id"]): idx for idx, itm in enumerate(self.items)} - if uid not in item_idx: - raise NoMatchingShoppingListItem(f"Item '{uid}' not found in shopping list") - if previous and previous not in item_idx: - raise NoMatchingShoppingListItem( - f"Item '{previous}' not found in shopping list" - ) - dst_idx = item_idx[previous] + 1 if previous else 0 - src_idx = item_idx[uid] - src_item = self.items.pop(src_idx) - if dst_idx > src_idx: - dst_idx -= 1 - self.items.insert(dst_idx, src_item) - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "reorder"}, - ) - - async def async_sort( - self, reverse: bool = False, context: Context | None = None - ) -> None: - """Sort items by name.""" - self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) # type: ignore[arg-type,return-value] - await self.hass.async_add_executor_job(self.save) - self._async_notify() - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "sorted"}, - context=context, - ) - - async def async_load(self) -> None: - """Load items.""" - - def load() -> list[dict[str, JsonValueType]]: - """Load the items synchronously.""" - return cast( - list[dict[str, JsonValueType]], - load_json_array(self.hass.config.path(PERSISTENCE)), - ) - - self.items = await self.hass.async_add_executor_job(load) - - def save(self) -> None: - """Save the items.""" - save_json(self.hass.config.path(PERSISTENCE), self.items) - - def async_add_listener(self, cb: Callable[[], None]) -> Callable[[], None]: - """Add a listener to notify when data is updated.""" - - def unsub() -> None: - self._listeners.remove(cb) - - self._listeners.append(cb) - return unsub - - def _async_notify(self) -> None: - """Notify all listeners that data has been updated.""" - for listener in self._listeners: - listener() - - class ShoppingListView(http.HomeAssistantView): """View to retrieve shopping list content.""" @@ -436,7 +191,7 @@ class ShoppingListView(http.HomeAssistantView): @callback def get(self, request: web.Request) -> web.Response: """Retrieve shopping list items.""" - return self.json(request.app[http.KEY_HASS].data[DOMAIN].items) + return self.json(_get_shopping_data(request.app[http.KEY_HASS]).items) class UpdateShoppingListItemView(http.HomeAssistantView): @@ -448,10 +203,10 @@ class UpdateShoppingListItemView(http.HomeAssistantView): async def post(self, request: web.Request, item_id: str) -> web.Response: """Update a shopping list item.""" data = await request.json() - hass = request.app[http.KEY_HASS] + shopping_data = _get_shopping_data(request.app[http.KEY_HASS]) try: - item = await hass.data[DOMAIN].async_update(item_id, data) + item = await shopping_data.async_update(item_id, data) return self.json(item) except NoMatchingShoppingListItem: return self.json_message("Item not found", HTTPStatus.NOT_FOUND) @@ -468,8 +223,8 @@ class CreateShoppingListItemView(http.HomeAssistantView): @RequestDataValidator(vol.Schema({vol.Required("name"): str})) async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Create a new shopping list item.""" - hass = request.app[http.KEY_HASS] - item = await hass.data[DOMAIN].async_add(data["name"]) + shopping_data = _get_shopping_data(request.app[http.KEY_HASS]) + item = await shopping_data.async_add(data["name"]) return self.json(item) @@ -481,8 +236,8 @@ class ClearCompletedItemsView(http.HomeAssistantView): async def post(self, request: web.Request) -> web.Response: """Retrieve if API is running.""" - hass = request.app[http.KEY_HASS] - await hass.data[DOMAIN].async_clear_completed() + shopping_data = _get_shopping_data(request.app[http.KEY_HASS]) + await shopping_data.async_clear_completed() return self.json_message("Cleared completed items.") @@ -495,7 +250,7 @@ def websocket_handle_items( ) -> None: """Handle getting shopping_list items.""" connection.send_message( - websocket_api.result_message(msg["id"], hass.data[DOMAIN].items) + websocket_api.result_message(msg["id"], _get_shopping_data(hass).items) ) @@ -509,7 +264,7 @@ async def websocket_handle_add( msg: dict[str, Any], ) -> None: """Handle adding item to shopping_list.""" - item = await hass.data[DOMAIN].async_add( + item = await _get_shopping_data(hass).async_add( msg["name"], context=connection.context(msg) ) connection.send_message(websocket_api.result_message(msg["id"], item)) @@ -530,7 +285,9 @@ async def websocket_handle_remove( msg.pop("type") try: - item = await hass.data[DOMAIN].async_remove(item_id, connection.context(msg)) + item = await _get_shopping_data(hass).async_remove( + item_id, connection.context(msg) + ) except NoMatchingShoppingListItem: connection.send_message( websocket_api.error_message(msg_id, "item_not_found", "Item not found") @@ -561,7 +318,7 @@ async def websocket_handle_update( data = msg try: - item = await hass.data[DOMAIN].async_update( + item = await _get_shopping_data(hass).async_update( item_id, data, connection.context(msg) ) except NoMatchingShoppingListItem: @@ -581,7 +338,8 @@ async def websocket_handle_clear( msg: dict[str, Any], ) -> None: """Handle clearing shopping_list items.""" - await hass.data[DOMAIN].async_clear_completed(connection.context(msg)) + shopping_data = _get_shopping_data(hass) + await shopping_data.async_clear_completed(connection.context(msg)) connection.send_message(websocket_api.result_message(msg["id"])) @@ -600,9 +358,8 @@ async def websocket_handle_reorder( """Handle reordering shopping_list items.""" msg_id = msg.pop("id") try: - await hass.data[DOMAIN].async_reorder( - msg.pop("item_ids"), connection.context(msg) - ) + shopping_data = _get_shopping_data(hass) + await shopping_data.async_reorder(msg.pop("item_ids"), connection.context(msg)) except NoMatchingShoppingListItem: connection.send_error( msg_id, diff --git a/homeassistant/components/shopping_list/common.py b/homeassistant/components/shopping_list/common.py new file mode 100644 index 00000000000000..bd2ecbe92ef4a2 --- /dev/null +++ b/homeassistant/components/shopping_list/common.py @@ -0,0 +1,281 @@ +"""Shopping list commons.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from typing import Any, cast +import uuid + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.json import save_json +from homeassistant.util.json import JsonValueType, load_json_array + +from .const import DOMAIN, EVENT_SHOPPING_LIST_UPDATED + +_LOGGER = logging.getLogger(__name__) + +ATTR_COMPLETE = "complete" + +ITEM_UPDATE_SCHEMA = vol.Schema({ATTR_COMPLETE: bool, ATTR_NAME: str}) +PERSISTENCE = ".shopping_list.json" + + +type ShoppingListConfigEntry = ConfigEntry[ShoppingData] + + +class NoMatchingShoppingListItem(Exception): + """No matching item could be found in the shopping list.""" + + +class ShoppingData: + """Class to hold shopping list data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the shopping list.""" + self.hass = hass + self.items: list[dict[str, JsonValueType]] = [] + self._listeners: list[Callable[[], None]] = [] + + async def async_add( + self, name: str | None, complete: bool = False, context: Context | None = None + ) -> dict[str, JsonValueType]: + """Add a shopping list item.""" + item: dict[str, JsonValueType] = { + "name": name, + "id": uuid.uuid4().hex, + "complete": complete, + } + self.items.append(item) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "add", "item": item}, + context=context, + ) + return item + + async def async_remove( + self, item_id: str, context: Context | None = None + ) -> dict[str, JsonValueType] | None: + """Remove a shopping list item.""" + removed = await self.async_remove_items( + item_ids=set({item_id}), context=context + ) + return next(iter(removed), None) + + async def async_remove_items( + self, item_ids: set[str], context: Context | None = None + ) -> list[dict[str, JsonValueType]]: + """Remove a shopping list item.""" + items_dict: dict[str, dict[str, JsonValueType]] = {} + for itm in self.items: + item_id = cast(str, itm["id"]) + items_dict[item_id] = itm + removed = [] + for item_id in item_ids: + _LOGGER.debug("Removing %s", item_id) + if not (item := items_dict.pop(item_id, None)): + raise NoMatchingShoppingListItem( + f"Item '{item_id}' not found in shopping list" + ) + removed.append(item) + self.items = list(items_dict.values()) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + for item in removed: + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "remove", "item": item}, + context=context, + ) + return removed + + async def async_complete( + self, name: str, context: Context | None = None + ) -> list[dict[str, JsonValueType]]: + """Mark all shopping list items with the given name as complete.""" + complete_items = [ + item for item in self.items if item["name"] == name and not item["complete"] + ] + + if len(complete_items) == 0: + raise NoMatchingShoppingListItem(f"No items with name '{name}' found") + + for item in complete_items: + _LOGGER.debug("Completing %s", item) + item["complete"] = True + await self.hass.async_add_executor_job(self.save) + self._async_notify() + for item in complete_items: + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "complete", "item": item}, + context=context, + ) + return complete_items + + async def async_update( + self, item_id: str | None, info: dict[str, Any], context: Context | None = None + ) -> dict[str, JsonValueType]: + """Update a shopping list item.""" + item = next((itm for itm in self.items if itm["id"] == item_id), None) + + if item is None: + raise NoMatchingShoppingListItem( + f"Item '{item_id}' not found in shopping list" + ) + + info = ITEM_UPDATE_SCHEMA(info) + item.update(info) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "update", "item": item}, + context=context, + ) + return item + + async def async_clear_completed(self, context: Context | None = None) -> None: + """Clear completed items.""" + self.items = [itm for itm in self.items if not itm["complete"]] + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "clear"}, + context=context, + ) + + async def async_update_list( + self, info: dict[str, JsonValueType], context: Context | None = None + ) -> list[dict[str, JsonValueType]]: + """Update all items in the list.""" + for item in self.items: + item.update(info) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "update_list"}, + context=context, + ) + return self.items + + async def async_reorder( + self, item_ids: list[str], context: Context | None = None + ) -> None: + """Reorder items.""" + # The array for sorted items. + new_items = [] + all_items_mapping = {item["id"]: item for item in self.items} + # Append items by the order of passed in array. + for item_id in item_ids: + if item_id not in all_items_mapping: + raise NoMatchingShoppingListItem( + f"Item '{item_id}' not found in shopping list" + ) + new_items.append(all_items_mapping[item_id]) + # Remove the item from mapping after it's appended in the result array. + del all_items_mapping[item_id] + # Append the rest of the items + for value in all_items_mapping.values(): + # All the unchecked items must be passed in the item_ids array, + # so all items left in the mapping should be checked items. + if value["complete"] is False: + raise vol.Invalid( + "The item ids array doesn't contain all the unchecked shopping list" + " items." + ) + new_items.append(value) + self.items = new_items + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "reorder"}, + context=context, + ) + + async def async_move_item(self, uid: str, previous: str | None = None) -> None: + """Re-order a shopping list item.""" + if uid == previous: + return + item_idx = {cast(str, itm["id"]): idx for idx, itm in enumerate(self.items)} + if uid not in item_idx: + raise NoMatchingShoppingListItem(f"Item '{uid}' not found in shopping list") + if previous and previous not in item_idx: + raise NoMatchingShoppingListItem( + f"Item '{previous}' not found in shopping list" + ) + dst_idx = item_idx[previous] + 1 if previous else 0 + src_idx = item_idx[uid] + src_item = self.items.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + self.items.insert(dst_idx, src_item) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "reorder"}, + ) + + async def async_sort( + self, reverse: bool = False, context: Context | None = None + ) -> None: + """Sort items by name.""" + self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) # type: ignore[arg-type,return-value] + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "sorted"}, + context=context, + ) + + async def async_load(self) -> None: + """Load items.""" + + def load() -> list[dict[str, JsonValueType]]: + """Load the items synchronously.""" + return cast( + list[dict[str, JsonValueType]], + load_json_array(self.hass.config.path(PERSISTENCE)), + ) + + self.items = await self.hass.async_add_executor_job(load) + + def save(self) -> None: + """Save the items.""" + save_json(self.hass.config.path(PERSISTENCE), self.items) + + def async_add_listener(self, cb: Callable[[], None]) -> Callable[[], None]: + """Add a listener to notify when data is updated.""" + + def unsub() -> None: + self._listeners.remove(cb) + + self._listeners.append(cb) + return unsub + + def _async_notify(self) -> None: + """Notify all listeners that data has been updated.""" + for listener in self._listeners: + listener() + + +def _get_shopping_data(hass: HomeAssistant) -> ShoppingData: + entries: list[ShoppingListConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + if not entries: + raise HomeAssistantError("No shopping list config entry found") + return entries[0].runtime_data diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index 06bb692621ad16..04fc3884ec4cdc 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -5,7 +5,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent -from . import DOMAIN, EVENT_SHOPPING_LIST_UPDATED, NoMatchingShoppingListItem +from .common import NoMatchingShoppingListItem, _get_shopping_data +from .const import DOMAIN, EVENT_SHOPPING_LIST_UPDATED INTENT_ADD_ITEM = "HassShoppingListAddItem" INTENT_COMPLETE_ITEM = "HassShoppingListCompleteItem" @@ -31,7 +32,7 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse """Handle the intent.""" slots = self.async_validate_slots(intent_obj.slots) item = slots["item"]["value"].strip() - await intent_obj.hass.data[DOMAIN].async_add(item) + await _get_shopping_data(intent_obj.hass).async_add(item) response = intent_obj.create_response() intent_obj.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED) @@ -52,7 +53,9 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse item = slots["item"]["value"].strip() try: - complete_items = await intent_obj.hass.data[DOMAIN].async_complete(item) + complete_items = await _get_shopping_data(intent_obj.hass).async_complete( + item + ) except NoMatchingShoppingListItem: complete_items = [] @@ -74,13 +77,13 @@ class ListTopItemsIntent(intent.IntentHandler): async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" - items = intent_obj.hass.data[DOMAIN].items[-5:] + items = _get_shopping_data(intent_obj.hass).items[-5:] response: intent.IntentResponse = intent_obj.create_response() if not items: response.async_set_speech("There are no items on your shopping list") else: - items_list = ", ".join(itm["name"] for itm in reversed(items)) + items_list = ", ".join(str(itm["name"]) for itm in reversed(items)) response.async_set_speech( f"These are the top {min(len(items), 5)} items on your shopping list: {items_list}" ) diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index 4fbab191aaa258..61b9c0b8048297 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -8,24 +8,20 @@ TodoListEntity, TodoListEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NoMatchingShoppingListItem, ShoppingData -from .const import DOMAIN +from .common import NoMatchingShoppingListItem, ShoppingData, ShoppingListConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ShoppingListConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the shopping_list todo platform.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - shopping_data = hass.data[DOMAIN] + shopping_data = config_entry.runtime_data entity = ShoppingTodoListEntity(shopping_data, unique_id=config_entry.entry_id) async_add_entities([entity], True) diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index cbb5d1d27bf4e9..b0dbf32ba76de5 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -224,10 +224,11 @@ async def _async_update_data(self) -> dict[str, Any]: or not isinstance(data.get("time_series"), list) or not data["time_series"] ): - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="invalid_energy_history_data", + _LOGGER.warning( + "Tessie returned no energy history time_series for coordinator %s; skipping update", + self.config_entry.entry_id, ) + return self.data time_series = data["time_series"] output: dict[str, Any] = {} diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 18ea9afc90fd14..f4cd3ef8e398d0 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -638,4 +638,4 @@ def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" self._attr_available = self._value is not None self._attr_native_value = self._value - self._attr_last_reset = self.coordinator.data["_period_start"] + self._attr_last_reset = self.coordinator.data.get("_period_start") diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index e813e5669c61ca..c4e0a48f5325ef 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.2.6"] + "requirements": ["uiprotect==10.3.0"] } diff --git a/homeassistant/components/victron_gx/__init__.py b/homeassistant/components/victron_gx/__init__.py index 1b02faa1410137..76f6e5f9dccc2a 100644 --- a/homeassistant/components/victron_gx/__init__.py +++ b/homeassistant/components/victron_gx/__init__.py @@ -6,6 +6,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr from .hub import Hub, VictronGxConfigEntry @@ -65,3 +66,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: VictronGxConfigEntry) - hub.unregister_all_new_metric_callbacks() return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove a device from the config entry if the device is no longer known.""" + hub: Hub = config_entry.runtime_data + return not hub.is_device_connected(device_entry.identifiers) diff --git a/homeassistant/components/victron_gx/hub.py b/homeassistant/components/victron_gx/hub.py index 5e6b579bac0e5e..111f85ac657156 100644 --- a/homeassistant/components/victron_gx/hub.py +++ b/homeassistant/components/victron_gx/hub.py @@ -89,11 +89,15 @@ async def start(self) -> None: await self._hub.connect() except AuthenticationError as auth_error: raise ConfigEntryAuthFailed( - f"Authentication failed for {self.host}: {auth_error}" + translation_domain=DOMAIN, + translation_key="authentication_failed", + translation_placeholders={"host": self.host}, ) from auth_error except CannotConnectError as connect_error: raise ConfigEntryNotReady( - f"Cannot connect to the hub at {self.host}: {connect_error}" + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": self.host}, ) from connect_error async def stop(self) -> None: @@ -142,6 +146,15 @@ def _map_device_info( device_info["via_device"] = (DOMAIN, f"{installation_id}_system_0") return device_info + def is_device_connected(self, device_identifiers: set[tuple[str, str]]) -> bool: + """Check if a device is currently known to the hub.""" + known_devices = self._hub.devices + return any( + identifier[1].removeprefix(f"{self._hub.installation_id}_") in known_devices + for identifier in device_identifiers + if identifier[0] == DOMAIN + ) + def get_diagnostics_data(self) -> dict[str, Any]: """Return diagnostics data for the hub's device and entity tree.""" return { diff --git a/homeassistant/components/victron_gx/quality_scale.yaml b/homeassistant/components/victron_gx/quality_scale.yaml index 0aec6d676e4ce3..fd608a85b32e11 100644 --- a/homeassistant/components/victron_gx/quality_scale.yaml +++ b/homeassistant/components/victron_gx/quality_scale.yaml @@ -57,14 +57,14 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: | Not relevant. reconfiguration-flow: todo repair-issues: todo - stale-devices: todo + stale-devices: done # Platinum async-dependency: done @@ -72,4 +72,4 @@ rules: status: exempt comment: | Not relevant. - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 64159a0c2a0c91..3bc8eef314b14a 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -1977,5 +1977,13 @@ "name": "ESS BatteryLife schedule charge {slot} start" } } + }, + "exceptions": { + "authentication_failed": { + "message": "Authentication failed for {host}." + }, + "cannot_connect": { + "message": "Cannot connect to the GX device at {host}." + } } } diff --git a/mypy.ini b/mypy.ini index 6f1be3bc048f2a..9f64f3e56506a7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5748,6 +5748,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.victron_gx.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.vivotek.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index c9cc32c1129ff8..ef272b2cbd6430 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -22,7 +22,7 @@ HAP-python==5.0.0 HATasmota==0.10.1 # homeassistant.components.hue_ble -HueBLE==2.1.0 +HueBLE==2.2.2 # homeassistant.components.mastodon Mastodon.py==2.2.1 @@ -2563,7 +2563,7 @@ python-awair==0.2.5 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==5.1.4 +python-bsblan==5.2.0 # homeassistant.components.citybikes python-citybikes==0.3.3 @@ -3188,7 +3188,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==10.2.6 +uiprotect==10.3.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df4662fad46e6c..53f6b1fe071b75 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -22,7 +22,7 @@ HAP-python==5.0.0 HATasmota==0.10.1 # homeassistant.components.hue_ble -HueBLE==2.1.0 +HueBLE==2.2.2 # homeassistant.components.mastodon Mastodon.py==2.2.1 @@ -2195,7 +2195,7 @@ python-MotionMount==2.3.0 python-awair==0.2.5 # homeassistant.components.bsblan -python-bsblan==5.1.4 +python-bsblan==5.2.0 # homeassistant.components.citybikes python-citybikes==0.3.3 @@ -2709,7 +2709,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==10.2.6 +uiprotect==10.3.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/tests/components/ai_task/test_media_source.py b/tests/components/ai_task/test_media_source.py index 11344acfb5e8a5..f41992f74cde47 100644 --- a/tests/components/ai_task/test_media_source.py +++ b/tests/components/ai_task/test_media_source.py @@ -12,11 +12,11 @@ async def test_local_media_source(hass: HomeAssistant, init_components: None) -> """Test that the image media source is created.""" item = await media_source.async_browse_media(hass, "media-source://") - assert any(c.title == "AI Generated Images" for c in item.children) + assert any(c.title == "AI generated images" for c in item.children) source = await async_get_media_source(hass) assert isinstance(source, media_source.local_source.LocalSource) - assert source.name == "AI Generated Images" + assert source.name == "AI generated images" assert source.domain == "ai_task" assert list(source.media_dirs) == ["image"] # Depending on Docker, the default is one of the two paths diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 5f94c26558bb38..90d004b7480ce9 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -41,6 +41,7 @@ TEST_DATA_CREATE_ENTRY_3, TEST_DATA_CREATE_ENTRY_4, TEST_FRIENDLY_NAME, + TEST_FRIENDLY_NAME_2, TEST_FRIENDLY_NAME_3, TEST_FRIENDLY_NAME_4, TEST_HOST_3, @@ -48,10 +49,6 @@ TEST_JID_1, TEST_JID_3, TEST_JID_4, - TEST_NAME, - TEST_NAME_2, - TEST_NAME_3, - TEST_NAME_4, TEST_REMOTE_SERIAL, TEST_SERIAL_NUMBER, TEST_SERIAL_NUMBER_2, @@ -72,7 +69,7 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, unique_id=TEST_SERIAL_NUMBER, data=TEST_DATA_CREATE_ENTRY, - title=TEST_NAME, + title=TEST_FRIENDLY_NAME, ) @@ -83,7 +80,7 @@ def mock_config_entry_core() -> MockConfigEntry: domain=DOMAIN, unique_id=TEST_SERIAL_NUMBER_2, data=TEST_DATA_CREATE_ENTRY_2, - title=TEST_NAME_2, + title=TEST_FRIENDLY_NAME_2, ) @@ -94,7 +91,7 @@ def mock_config_entry_premiere() -> MockConfigEntry: domain=DOMAIN, unique_id=TEST_SERIAL_NUMBER_3, data=TEST_DATA_CREATE_ENTRY_3, - title=TEST_NAME_3, + title=TEST_FRIENDLY_NAME_3, ) @@ -105,7 +102,7 @@ def mock_config_entry_a5() -> MockConfigEntry: domain=DOMAIN, unique_id=TEST_SERIAL_NUMBER_4, data=TEST_DATA_CREATE_ENTRY_4, - title=TEST_NAME_4, + title=TEST_FRIENDLY_NAME_4, ) diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 53e86f83e2f3a2..35a7be68590945 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -49,32 +49,30 @@ TEST_TYPE_NUMBER = "1111" TEST_ITEM_NUMBER = "1111111" TEST_JID_1 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER}@products.bang-olufsen.com" -TEST_MEDIA_PLAYER_ENTITY_ID = "media_player.beosound_balance_11111111" +TEST_MEDIA_PLAYER_ENTITY_ID = "media_player.living_room_balance" TEST_FRIENDLY_NAME_2 = "Laundry room Core" TEST_SERIAL_NUMBER_2 = "22222222" TEST_NAME_2 = f"{TEST_MODEL_CORE}-{TEST_SERIAL_NUMBER_2}" TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_2}@products.bang-olufsen.com" -TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beoconnect_core_22222222" +TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.laundry_room_core" TEST_HOST_2 = "192.168.0.2" TEST_FRIENDLY_NAME_3 = "Bedroom Premiere" TEST_SERIAL_NUMBER_3 = "33333333" TEST_NAME_3 = f"{TEST_MODEL_PREMIERE}-{TEST_SERIAL_NUMBER_3}" TEST_JID_3 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_3}@products.bang-olufsen.com" -TEST_MEDIA_PLAYER_ENTITY_ID_3 = f"media_player.beosound_premiere_{TEST_SERIAL_NUMBER_3}" +TEST_MEDIA_PLAYER_ENTITY_ID_3 = "media_player.bedroom_premiere" TEST_HOST_3 = "192.168.0.3" TEST_FRIENDLY_NAME_4 = "Lounge room A5" TEST_SERIAL_NUMBER_4 = "44444444" TEST_NAME_4 = f"{TEST_MODEL_A5}-{TEST_SERIAL_NUMBER_4}" TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_4}@products.bang-olufsen.com" -TEST_MEDIA_PLAYER_ENTITY_ID_4 = f"media_player.beosound_a5_{TEST_SERIAL_NUMBER_4}" +TEST_MEDIA_PLAYER_ENTITY_ID_4 = "media_player.lounge_room_a5" TEST_HOST_4 = "192.168.0.4" -TEST_BATTERY_SENSOR_ENTITY_ID = f"sensor.beosound_a5_{TEST_SERIAL_NUMBER_4}_battery" -TEST_BATTERY_CHARGING_BINARY_SENSOR_ENTITY_ID = ( - f"binary_sensor.beosound_a5_{TEST_SERIAL_NUMBER_4}_charging" -) +TEST_BATTERY_SENSOR_ENTITY_ID = "sensor.lounge_room_a5_battery" +TEST_BATTERY_CHARGING_BINARY_SENSOR_ENTITY_ID = "binary_sensor.lounge_room_a5_charging" # Beoremote One TEST_REMOTE_SERIAL = "55555555" @@ -85,7 +83,7 @@ TEST_REMOTE_BATTERY_LEVEL_SENSOR_ENTITY_ID = ( "sensor.beoremote_one_55555555_11111111_battery" ) -TEST_BUTTON_EVENT_ENTITY_ID = "event.beosound_balance_11111111_play_pause" +TEST_BUTTON_EVENT_ENTITY_ID = "event.living_room_balance_play_pause" TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local." TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local." @@ -99,27 +97,27 @@ CONF_HOST: TEST_HOST, CONF_MODEL: TEST_MODEL_BALANCE, CONF_BEOLINK_JID: TEST_JID_1, - CONF_NAME: TEST_NAME, + CONF_NAME: TEST_FRIENDLY_NAME, } TEST_DATA_CREATE_ENTRY_2 = { CONF_HOST: TEST_HOST_2, CONF_MODEL: TEST_MODEL_CORE, CONF_BEOLINK_JID: TEST_JID_2, - CONF_NAME: TEST_NAME_2, + CONF_NAME: TEST_FRIENDLY_NAME_2, } TEST_DATA_CREATE_ENTRY_3 = { CONF_HOST: TEST_HOST_3, CONF_MODEL: TEST_MODEL_PREMIERE, CONF_BEOLINK_JID: TEST_JID_3, - CONF_NAME: TEST_NAME_3, + CONF_NAME: TEST_FRIENDLY_NAME_3, } TEST_DATA_CREATE_ENTRY_4 = { CONF_HOST: TEST_HOST_4, CONF_MODEL: TEST_MODEL_A5, CONF_BEOLINK_JID: TEST_JID_4, - CONF_NAME: TEST_NAME_4, + CONF_NAME: TEST_FRIENDLY_NAME_4, } TEST_DATA_ZEROCONF = ZeroconfServiceInfo( diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index 0d45adf710b2a7..10066babd8f032 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -14,7 +14,7 @@ ]), 'friendly_name': 'Living room Balance Play / Pause', }), - 'entity_id': 'event.beosound_balance_11111111_play_pause', + 'entity_id': 'event.living_room_balance_play_pause', 'state': 'unknown', }), 'config_entry': dict({ @@ -22,7 +22,7 @@ 'host': '192.168.0.1', 'jid': '1111.1111111.11111111@products.bang-olufsen.com', 'model': 'Beosound Balance', - 'name': 'Beosound Balance-11111111', + 'name': 'Living room Balance', }), 'disabled_by': None, 'discovery_keys': dict({ @@ -36,7 +36,7 @@ 'source': 'user', 'subentries': list([ ]), - 'title': 'Beosound Balance-11111111', + 'title': 'Living room Balance', 'unique_id': '11111111', 'version': 1, }), @@ -59,7 +59,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -79,7 +79,7 @@ ]), 'supported_features': 2095933, }), - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'state': 'playing', }), 'remote_55555555': dict({ @@ -128,7 +128,7 @@ 'state_class': 'measurement', 'unit_of_measurement': '%', }), - 'entity_id': 'sensor.beosound_a5_44444444_battery', + 'entity_id': 'sensor.lounge_room_a5_battery', 'state': '5', }), 'charging': dict({ @@ -136,7 +136,7 @@ 'device_class': 'battery_charging', 'friendly_name': 'Living room Balance Charging', }), - 'entity_id': 'binary_sensor.beosound_a5_44444444_charging', + 'entity_id': 'binary_sensor.lounge_room_a5_charging', 'state': 'off', }), 'config_entry': dict({ @@ -144,7 +144,7 @@ 'host': '192.168.0.4', 'jid': '1111.1111111.44444444@products.bang-olufsen.com', 'model': 'Beosound A5', - 'name': 'Beosound A5-44444444', + 'name': 'Lounge room A5', }), 'disabled_by': None, 'discovery_keys': dict({ @@ -158,7 +158,7 @@ 'source': 'user', 'subentries': list([ ]), - 'title': 'Beosound A5-44444444', + 'title': 'Lounge room A5', 'unique_id': '44444444', 'version': 1, }), @@ -181,9 +181,9 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_a5_44444444', + 'media_player.lounge_room_a5', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', - 'media_player.beosound_a5_44444444', + 'media_player.lounge_room_a5', ]), 'media_content_type': 'music', 'repeat': 'off', @@ -201,7 +201,7 @@ ]), 'supported_features': 2095933, }), - 'entity_id': 'media_player.beosound_a5_44444444', + 'entity_id': 'media_player.lounge_room_a5', 'state': 'playing', }), 'remote_55555555': dict({ diff --git a/tests/components/bang_olufsen/snapshots/test_event.ambr b/tests/components/bang_olufsen/snapshots/test_event.ambr index f0ee135f98eb8c..90ce7cb12742eb 100644 --- a/tests/components/bang_olufsen/snapshots/test_event.ambr +++ b/tests/components/bang_olufsen/snapshots/test_event.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_button_event_creation_a5 list([ - 'binary_sensor.beosound_a5_44444444_charging', + 'binary_sensor.lounge_room_a5_charging', 'event.beoremote_one_55555555_44444444_control_blue', 'event.beoremote_one_55555555_44444444_control_digit_0', 'event.beoremote_one_55555555_44444444_control_digit_1', @@ -92,18 +92,18 @@ 'event.beoremote_one_55555555_44444444_light_up', 'event.beoremote_one_55555555_44444444_light_wind', 'event.beoremote_one_55555555_44444444_light_yellow', - 'event.beosound_a5_44444444_bluetooth', - 'event.beosound_a5_44444444_favorite_1', - 'event.beosound_a5_44444444_favorite_2', - 'event.beosound_a5_44444444_favorite_3', - 'event.beosound_a5_44444444_favorite_4', - 'event.beosound_a5_44444444_next', - 'event.beosound_a5_44444444_play_pause', - 'event.beosound_a5_44444444_previous', - 'event.beosound_a5_44444444_volume', - 'media_player.beosound_a5_44444444', + 'event.lounge_room_a5_bluetooth', + 'event.lounge_room_a5_favorite_1', + 'event.lounge_room_a5_favorite_2', + 'event.lounge_room_a5_favorite_3', + 'event.lounge_room_a5_favorite_4', + 'event.lounge_room_a5_next', + 'event.lounge_room_a5_play_pause', + 'event.lounge_room_a5_previous', + 'event.lounge_room_a5_volume', + 'media_player.lounge_room_a5', 'sensor.beoremote_one_55555555_44444444_battery', - 'sensor.beosound_a5_44444444_battery', + 'sensor.lounge_room_a5_battery', ]) # --- # name: test_button_event_creation_balance @@ -198,22 +198,30 @@ 'event.beoremote_one_55555555_11111111_light_up', 'event.beoremote_one_55555555_11111111_light_wind', 'event.beoremote_one_55555555_11111111_light_yellow', - 'event.beosound_balance_11111111_bluetooth', - 'event.beosound_balance_11111111_favorite_1', - 'event.beosound_balance_11111111_favorite_2', - 'event.beosound_balance_11111111_favorite_3', - 'event.beosound_balance_11111111_favorite_4', - 'event.beosound_balance_11111111_microphone', - 'event.beosound_balance_11111111_next', - 'event.beosound_balance_11111111_play_pause', - 'event.beosound_balance_11111111_previous', - 'event.beosound_balance_11111111_volume', - 'media_player.beosound_balance_11111111', + 'event.living_room_balance_bluetooth', + 'event.living_room_balance_favorite_1', + 'event.living_room_balance_favorite_2', + 'event.living_room_balance_favorite_3', + 'event.living_room_balance_favorite_4', + 'event.living_room_balance_microphone', + 'event.living_room_balance_next', + 'event.living_room_balance_play_pause', + 'event.living_room_balance_previous', + 'event.living_room_balance_volume', + 'media_player.living_room_balance', 'sensor.beoremote_one_55555555_11111111_battery', ]) # --- # name: test_button_event_creation_premiere list([ + 'event.bedroom_premiere_favorite_1', + 'event.bedroom_premiere_favorite_2', + 'event.bedroom_premiere_favorite_3', + 'event.bedroom_premiere_favorite_4', + 'event.bedroom_premiere_next', + 'event.bedroom_premiere_play_pause', + 'event.bedroom_premiere_previous', + 'event.bedroom_premiere_volume', 'event.beoremote_one_55555555_33333333_control_blue', 'event.beoremote_one_55555555_33333333_control_digit_0', 'event.beoremote_one_55555555_33333333_control_digit_1', @@ -304,20 +312,12 @@ 'event.beoremote_one_55555555_33333333_light_up', 'event.beoremote_one_55555555_33333333_light_wind', 'event.beoremote_one_55555555_33333333_light_yellow', - 'event.beosound_premiere_33333333_favorite_1', - 'event.beosound_premiere_33333333_favorite_2', - 'event.beosound_premiere_33333333_favorite_3', - 'event.beosound_premiere_33333333_favorite_4', - 'event.beosound_premiere_33333333_next', - 'event.beosound_premiere_33333333_play_pause', - 'event.beosound_premiere_33333333_previous', - 'event.beosound_premiere_33333333_volume', - 'media_player.beosound_premiere_33333333', + 'media_player.bedroom_premiere', 'sensor.beoremote_one_55555555_33333333_battery', ]) # --- # name: test_no_button_and_remote_key_event_creation_core list([ - 'media_player.beoconnect_core_22222222', + 'media_player.laundry_room_core', ]) # --- diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index c62490f3bf94bf..add51d3bb173a2 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -19,7 +19,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -40,7 +40,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -67,7 +67,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -89,7 +89,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -116,7 +116,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -138,7 +138,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -165,7 +165,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -187,7 +187,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -214,7 +214,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -236,7 +236,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -263,7 +263,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -284,7 +284,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -311,7 +311,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -332,7 +332,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -359,7 +359,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -380,7 +380,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -407,7 +407,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -428,7 +428,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -455,7 +455,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -476,7 +476,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -503,7 +503,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -524,7 +524,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -551,7 +551,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -572,7 +572,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -599,7 +599,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -621,7 +621,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -648,7 +648,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beoconnect_core_22222222', + 'media_player.laundry_room_core', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -670,7 +670,7 @@ 'volume_level': 0.0, }), 'context': , - 'entity_id': 'media_player.beoconnect_core_22222222', + 'entity_id': 'media_player.laundry_room_core', 'last_changed': , 'last_reported': , 'last_updated': , @@ -697,7 +697,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -719,7 +719,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -746,7 +746,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beoconnect_core_22222222', + 'media_player.laundry_room_core', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -768,7 +768,7 @@ 'volume_level': 0.0, }), 'context': , - 'entity_id': 'media_player.beoconnect_core_22222222', + 'entity_id': 'media_player.laundry_room_core', 'last_changed': , 'last_reported': , 'last_updated': , @@ -795,7 +795,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -818,7 +818,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -845,7 +845,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beoconnect_core_22222222', + 'media_player.laundry_room_core', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -867,7 +867,7 @@ 'volume_level': 0.0, }), 'context': , - 'entity_id': 'media_player.beoconnect_core_22222222', + 'entity_id': 'media_player.laundry_room_core', 'last_changed': , 'last_reported': , 'last_updated': , @@ -894,7 +894,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -916,7 +916,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -943,7 +943,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beoconnect_core_22222222', + 'media_player.laundry_room_core', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -965,7 +965,7 @@ 'volume_level': 0.0, }), 'context': , - 'entity_id': 'media_player.beoconnect_core_22222222', + 'entity_id': 'media_player.laundry_room_core', 'last_changed': , 'last_reported': , 'last_updated': , @@ -992,7 +992,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_11111111', + 'media_player.living_room_balance', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -1013,7 +1013,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1039,8 +1039,8 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beoconnect_core_22222222', - 'media_player.beosound_balance_11111111', + 'media_player.laundry_room_core', + 'media_player.living_room_balance', ]), 'media_content_type': , 'repeat': , @@ -1059,7 +1059,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_11111111', + 'entity_id': 'media_player.living_room_balance', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1086,7 +1086,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beoconnect_core_22222222', + 'media_player.laundry_room_core', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -1108,7 +1108,7 @@ 'volume_level': 0.0, }), 'context': , - 'entity_id': 'media_player.beoconnect_core_22222222', + 'entity_id': 'media_player.laundry_room_core', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/bang_olufsen/snapshots/test_websocket.ambr b/tests/components/bang_olufsen/snapshots/test_websocket.ambr index 642854fb9ed788..133e483829c2d6 100644 --- a/tests/components/bang_olufsen/snapshots/test_websocket.ambr +++ b/tests/components/bang_olufsen/snapshots/test_websocket.ambr @@ -1,56 +1,6 @@ # serializer version: 1 # name: test_on_remote_control_already_added list([ - 'event.beosound_balance_11111111_bluetooth', - 'event.beosound_balance_11111111_microphone', - 'event.beosound_balance_11111111_next', - 'event.beosound_balance_11111111_play_pause', - 'event.beosound_balance_11111111_favorite_1', - 'event.beosound_balance_11111111_favorite_2', - 'event.beosound_balance_11111111_favorite_3', - 'event.beosound_balance_11111111_favorite_4', - 'event.beosound_balance_11111111_previous', - 'event.beosound_balance_11111111_volume', - 'event.beoremote_one_55555555_11111111_light_blue', - 'event.beoremote_one_55555555_11111111_light_digit_0', - 'event.beoremote_one_55555555_11111111_light_digit_1', - 'event.beoremote_one_55555555_11111111_light_digit_2', - 'event.beoremote_one_55555555_11111111_light_digit_3', - 'event.beoremote_one_55555555_11111111_light_digit_4', - 'event.beoremote_one_55555555_11111111_light_digit_5', - 'event.beoremote_one_55555555_11111111_light_digit_6', - 'event.beoremote_one_55555555_11111111_light_digit_7', - 'event.beoremote_one_55555555_11111111_light_digit_8', - 'event.beoremote_one_55555555_11111111_light_digit_9', - 'event.beoremote_one_55555555_11111111_light_down', - 'event.beoremote_one_55555555_11111111_light_green', - 'event.beoremote_one_55555555_11111111_light_left', - 'event.beoremote_one_55555555_11111111_light_play', - 'event.beoremote_one_55555555_11111111_light_red', - 'event.beoremote_one_55555555_11111111_light_rewind', - 'event.beoremote_one_55555555_11111111_light_right', - 'event.beoremote_one_55555555_11111111_light_select', - 'event.beoremote_one_55555555_11111111_light_stop', - 'event.beoremote_one_55555555_11111111_light_up', - 'event.beoremote_one_55555555_11111111_light_wind', - 'event.beoremote_one_55555555_11111111_light_yellow', - 'event.beoremote_one_55555555_11111111_light_function_1', - 'event.beoremote_one_55555555_11111111_light_function_2', - 'event.beoremote_one_55555555_11111111_light_function_3', - 'event.beoremote_one_55555555_11111111_light_function_4', - 'event.beoremote_one_55555555_11111111_light_function_5', - 'event.beoremote_one_55555555_11111111_light_function_6', - 'event.beoremote_one_55555555_11111111_light_function_7', - 'event.beoremote_one_55555555_11111111_light_function_8', - 'event.beoremote_one_55555555_11111111_light_function_9', - 'event.beoremote_one_55555555_11111111_light_function_10', - 'event.beoremote_one_55555555_11111111_light_function_11', - 'event.beoremote_one_55555555_11111111_light_function_12', - 'event.beoremote_one_55555555_11111111_light_function_13', - 'event.beoremote_one_55555555_11111111_light_function_14', - 'event.beoremote_one_55555555_11111111_light_function_15', - 'event.beoremote_one_55555555_11111111_light_function_16', - 'event.beoremote_one_55555555_11111111_light_function_17', 'event.beoremote_one_55555555_11111111_control_blue', 'event.beoremote_one_55555555_11111111_control_digit_0', 'event.beoremote_one_55555555_11111111_control_digit_1', @@ -63,26 +13,7 @@ 'event.beoremote_one_55555555_11111111_control_digit_8', 'event.beoremote_one_55555555_11111111_control_digit_9', 'event.beoremote_one_55555555_11111111_control_down', - 'event.beoremote_one_55555555_11111111_control_green', - 'event.beoremote_one_55555555_11111111_control_left', - 'event.beoremote_one_55555555_11111111_control_play', - 'event.beoremote_one_55555555_11111111_control_red', - 'event.beoremote_one_55555555_11111111_control_rewind', - 'event.beoremote_one_55555555_11111111_control_right', - 'event.beoremote_one_55555555_11111111_control_select', - 'event.beoremote_one_55555555_11111111_control_stop', - 'event.beoremote_one_55555555_11111111_control_up', - 'event.beoremote_one_55555555_11111111_control_wind', - 'event.beoremote_one_55555555_11111111_control_yellow', 'event.beoremote_one_55555555_11111111_control_function_1', - 'event.beoremote_one_55555555_11111111_control_function_2', - 'event.beoremote_one_55555555_11111111_control_function_3', - 'event.beoremote_one_55555555_11111111_control_function_4', - 'event.beoremote_one_55555555_11111111_control_function_5', - 'event.beoremote_one_55555555_11111111_control_function_6', - 'event.beoremote_one_55555555_11111111_control_function_7', - 'event.beoremote_one_55555555_11111111_control_function_8', - 'event.beoremote_one_55555555_11111111_control_function_9', 'event.beoremote_one_55555555_11111111_control_function_10', 'event.beoremote_one_55555555_11111111_control_function_11', 'event.beoremote_one_55555555_11111111_control_function_12', @@ -93,6 +24,7 @@ 'event.beoremote_one_55555555_11111111_control_function_17', 'event.beoremote_one_55555555_11111111_control_function_18', 'event.beoremote_one_55555555_11111111_control_function_19', + 'event.beoremote_one_55555555_11111111_control_function_2', 'event.beoremote_one_55555555_11111111_control_function_20', 'event.beoremote_one_55555555_11111111_control_function_21', 'event.beoremote_one_55555555_11111111_control_function_22', @@ -101,22 +33,24 @@ 'event.beoremote_one_55555555_11111111_control_function_25', 'event.beoremote_one_55555555_11111111_control_function_26', 'event.beoremote_one_55555555_11111111_control_function_27', - 'sensor.beoremote_one_55555555_11111111_battery', - 'media_player.beosound_balance_11111111', - ]) -# --- -# name: test_on_remote_control_paired - list([ - 'event.beosound_balance_11111111_bluetooth', - 'event.beosound_balance_11111111_microphone', - 'event.beosound_balance_11111111_next', - 'event.beosound_balance_11111111_play_pause', - 'event.beosound_balance_11111111_favorite_1', - 'event.beosound_balance_11111111_favorite_2', - 'event.beosound_balance_11111111_favorite_3', - 'event.beosound_balance_11111111_favorite_4', - 'event.beosound_balance_11111111_previous', - 'event.beosound_balance_11111111_volume', + 'event.beoremote_one_55555555_11111111_control_function_3', + 'event.beoremote_one_55555555_11111111_control_function_4', + 'event.beoremote_one_55555555_11111111_control_function_5', + 'event.beoremote_one_55555555_11111111_control_function_6', + 'event.beoremote_one_55555555_11111111_control_function_7', + 'event.beoremote_one_55555555_11111111_control_function_8', + 'event.beoremote_one_55555555_11111111_control_function_9', + 'event.beoremote_one_55555555_11111111_control_green', + 'event.beoremote_one_55555555_11111111_control_left', + 'event.beoremote_one_55555555_11111111_control_play', + 'event.beoremote_one_55555555_11111111_control_red', + 'event.beoremote_one_55555555_11111111_control_rewind', + 'event.beoremote_one_55555555_11111111_control_right', + 'event.beoremote_one_55555555_11111111_control_select', + 'event.beoremote_one_55555555_11111111_control_stop', + 'event.beoremote_one_55555555_11111111_control_up', + 'event.beoremote_one_55555555_11111111_control_wind', + 'event.beoremote_one_55555555_11111111_control_yellow', 'event.beoremote_one_55555555_11111111_light_blue', 'event.beoremote_one_55555555_11111111_light_digit_0', 'event.beoremote_one_55555555_11111111_light_digit_1', @@ -129,6 +63,23 @@ 'event.beoremote_one_55555555_11111111_light_digit_8', 'event.beoremote_one_55555555_11111111_light_digit_9', 'event.beoremote_one_55555555_11111111_light_down', + 'event.beoremote_one_55555555_11111111_light_function_1', + 'event.beoremote_one_55555555_11111111_light_function_10', + 'event.beoremote_one_55555555_11111111_light_function_11', + 'event.beoremote_one_55555555_11111111_light_function_12', + 'event.beoremote_one_55555555_11111111_light_function_13', + 'event.beoremote_one_55555555_11111111_light_function_14', + 'event.beoremote_one_55555555_11111111_light_function_15', + 'event.beoremote_one_55555555_11111111_light_function_16', + 'event.beoremote_one_55555555_11111111_light_function_17', + 'event.beoremote_one_55555555_11111111_light_function_2', + 'event.beoremote_one_55555555_11111111_light_function_3', + 'event.beoremote_one_55555555_11111111_light_function_4', + 'event.beoremote_one_55555555_11111111_light_function_5', + 'event.beoremote_one_55555555_11111111_light_function_6', + 'event.beoremote_one_55555555_11111111_light_function_7', + 'event.beoremote_one_55555555_11111111_light_function_8', + 'event.beoremote_one_55555555_11111111_light_function_9', 'event.beoremote_one_55555555_11111111_light_green', 'event.beoremote_one_55555555_11111111_light_left', 'event.beoremote_one_55555555_11111111_light_play', @@ -140,23 +91,22 @@ 'event.beoremote_one_55555555_11111111_light_up', 'event.beoremote_one_55555555_11111111_light_wind', 'event.beoremote_one_55555555_11111111_light_yellow', - 'event.beoremote_one_55555555_11111111_light_function_1', - 'event.beoremote_one_55555555_11111111_light_function_2', - 'event.beoremote_one_55555555_11111111_light_function_3', - 'event.beoremote_one_55555555_11111111_light_function_4', - 'event.beoremote_one_55555555_11111111_light_function_5', - 'event.beoremote_one_55555555_11111111_light_function_6', - 'event.beoremote_one_55555555_11111111_light_function_7', - 'event.beoremote_one_55555555_11111111_light_function_8', - 'event.beoremote_one_55555555_11111111_light_function_9', - 'event.beoremote_one_55555555_11111111_light_function_10', - 'event.beoremote_one_55555555_11111111_light_function_11', - 'event.beoremote_one_55555555_11111111_light_function_12', - 'event.beoremote_one_55555555_11111111_light_function_13', - 'event.beoremote_one_55555555_11111111_light_function_14', - 'event.beoremote_one_55555555_11111111_light_function_15', - 'event.beoremote_one_55555555_11111111_light_function_16', - 'event.beoremote_one_55555555_11111111_light_function_17', + 'event.living_room_balance_bluetooth', + 'event.living_room_balance_favorite_1', + 'event.living_room_balance_favorite_2', + 'event.living_room_balance_favorite_3', + 'event.living_room_balance_favorite_4', + 'event.living_room_balance_microphone', + 'event.living_room_balance_next', + 'event.living_room_balance_play_pause', + 'event.living_room_balance_previous', + 'event.living_room_balance_volume', + 'media_player.living_room_balance', + 'sensor.beoremote_one_55555555_11111111_battery', + ]) +# --- +# name: test_on_remote_control_paired + list([ 'event.beoremote_one_55555555_11111111_control_blue', 'event.beoremote_one_55555555_11111111_control_digit_0', 'event.beoremote_one_55555555_11111111_control_digit_1', @@ -169,26 +119,7 @@ 'event.beoremote_one_55555555_11111111_control_digit_8', 'event.beoremote_one_55555555_11111111_control_digit_9', 'event.beoremote_one_55555555_11111111_control_down', - 'event.beoremote_one_55555555_11111111_control_green', - 'event.beoremote_one_55555555_11111111_control_left', - 'event.beoremote_one_55555555_11111111_control_play', - 'event.beoremote_one_55555555_11111111_control_red', - 'event.beoremote_one_55555555_11111111_control_rewind', - 'event.beoremote_one_55555555_11111111_control_right', - 'event.beoremote_one_55555555_11111111_control_select', - 'event.beoremote_one_55555555_11111111_control_stop', - 'event.beoremote_one_55555555_11111111_control_up', - 'event.beoremote_one_55555555_11111111_control_wind', - 'event.beoremote_one_55555555_11111111_control_yellow', 'event.beoremote_one_55555555_11111111_control_function_1', - 'event.beoremote_one_55555555_11111111_control_function_2', - 'event.beoremote_one_55555555_11111111_control_function_3', - 'event.beoremote_one_55555555_11111111_control_function_4', - 'event.beoremote_one_55555555_11111111_control_function_5', - 'event.beoremote_one_55555555_11111111_control_function_6', - 'event.beoremote_one_55555555_11111111_control_function_7', - 'event.beoremote_one_55555555_11111111_control_function_8', - 'event.beoremote_one_55555555_11111111_control_function_9', 'event.beoremote_one_55555555_11111111_control_function_10', 'event.beoremote_one_55555555_11111111_control_function_11', 'event.beoremote_one_55555555_11111111_control_function_12', @@ -199,6 +130,7 @@ 'event.beoremote_one_55555555_11111111_control_function_17', 'event.beoremote_one_55555555_11111111_control_function_18', 'event.beoremote_one_55555555_11111111_control_function_19', + 'event.beoremote_one_55555555_11111111_control_function_2', 'event.beoremote_one_55555555_11111111_control_function_20', 'event.beoremote_one_55555555_11111111_control_function_21', 'event.beoremote_one_55555555_11111111_control_function_22', @@ -207,48 +139,64 @@ 'event.beoremote_one_55555555_11111111_control_function_25', 'event.beoremote_one_55555555_11111111_control_function_26', 'event.beoremote_one_55555555_11111111_control_function_27', - 'sensor.beoremote_one_55555555_11111111_battery', - 'media_player.beosound_balance_11111111', - 'event.beoremote_one_66666666_11111111_light_blue', - 'event.beoremote_one_66666666_11111111_light_digit_0', - 'event.beoremote_one_66666666_11111111_light_digit_1', - 'event.beoremote_one_66666666_11111111_light_digit_2', - 'event.beoremote_one_66666666_11111111_light_digit_3', - 'event.beoremote_one_66666666_11111111_light_digit_4', - 'event.beoremote_one_66666666_11111111_light_digit_5', - 'event.beoremote_one_66666666_11111111_light_digit_6', - 'event.beoremote_one_66666666_11111111_light_digit_7', - 'event.beoremote_one_66666666_11111111_light_digit_8', - 'event.beoremote_one_66666666_11111111_light_digit_9', - 'event.beoremote_one_66666666_11111111_light_down', - 'event.beoremote_one_66666666_11111111_light_green', - 'event.beoremote_one_66666666_11111111_light_left', - 'event.beoremote_one_66666666_11111111_light_play', - 'event.beoremote_one_66666666_11111111_light_red', - 'event.beoremote_one_66666666_11111111_light_rewind', - 'event.beoremote_one_66666666_11111111_light_right', - 'event.beoremote_one_66666666_11111111_light_select', - 'event.beoremote_one_66666666_11111111_light_stop', - 'event.beoremote_one_66666666_11111111_light_up', - 'event.beoremote_one_66666666_11111111_light_wind', - 'event.beoremote_one_66666666_11111111_light_yellow', - 'event.beoremote_one_66666666_11111111_light_function_1', - 'event.beoremote_one_66666666_11111111_light_function_2', - 'event.beoremote_one_66666666_11111111_light_function_3', - 'event.beoremote_one_66666666_11111111_light_function_4', - 'event.beoremote_one_66666666_11111111_light_function_5', - 'event.beoremote_one_66666666_11111111_light_function_6', - 'event.beoremote_one_66666666_11111111_light_function_7', - 'event.beoremote_one_66666666_11111111_light_function_8', - 'event.beoremote_one_66666666_11111111_light_function_9', - 'event.beoremote_one_66666666_11111111_light_function_10', - 'event.beoremote_one_66666666_11111111_light_function_11', - 'event.beoremote_one_66666666_11111111_light_function_12', - 'event.beoremote_one_66666666_11111111_light_function_13', - 'event.beoremote_one_66666666_11111111_light_function_14', - 'event.beoremote_one_66666666_11111111_light_function_15', - 'event.beoremote_one_66666666_11111111_light_function_16', - 'event.beoremote_one_66666666_11111111_light_function_17', + 'event.beoremote_one_55555555_11111111_control_function_3', + 'event.beoremote_one_55555555_11111111_control_function_4', + 'event.beoremote_one_55555555_11111111_control_function_5', + 'event.beoremote_one_55555555_11111111_control_function_6', + 'event.beoremote_one_55555555_11111111_control_function_7', + 'event.beoremote_one_55555555_11111111_control_function_8', + 'event.beoremote_one_55555555_11111111_control_function_9', + 'event.beoremote_one_55555555_11111111_control_green', + 'event.beoremote_one_55555555_11111111_control_left', + 'event.beoremote_one_55555555_11111111_control_play', + 'event.beoremote_one_55555555_11111111_control_red', + 'event.beoremote_one_55555555_11111111_control_rewind', + 'event.beoremote_one_55555555_11111111_control_right', + 'event.beoremote_one_55555555_11111111_control_select', + 'event.beoremote_one_55555555_11111111_control_stop', + 'event.beoremote_one_55555555_11111111_control_up', + 'event.beoremote_one_55555555_11111111_control_wind', + 'event.beoremote_one_55555555_11111111_control_yellow', + 'event.beoremote_one_55555555_11111111_light_blue', + 'event.beoremote_one_55555555_11111111_light_digit_0', + 'event.beoremote_one_55555555_11111111_light_digit_1', + 'event.beoremote_one_55555555_11111111_light_digit_2', + 'event.beoremote_one_55555555_11111111_light_digit_3', + 'event.beoremote_one_55555555_11111111_light_digit_4', + 'event.beoremote_one_55555555_11111111_light_digit_5', + 'event.beoremote_one_55555555_11111111_light_digit_6', + 'event.beoremote_one_55555555_11111111_light_digit_7', + 'event.beoremote_one_55555555_11111111_light_digit_8', + 'event.beoremote_one_55555555_11111111_light_digit_9', + 'event.beoremote_one_55555555_11111111_light_down', + 'event.beoremote_one_55555555_11111111_light_function_1', + 'event.beoremote_one_55555555_11111111_light_function_10', + 'event.beoremote_one_55555555_11111111_light_function_11', + 'event.beoremote_one_55555555_11111111_light_function_12', + 'event.beoremote_one_55555555_11111111_light_function_13', + 'event.beoremote_one_55555555_11111111_light_function_14', + 'event.beoremote_one_55555555_11111111_light_function_15', + 'event.beoremote_one_55555555_11111111_light_function_16', + 'event.beoremote_one_55555555_11111111_light_function_17', + 'event.beoremote_one_55555555_11111111_light_function_2', + 'event.beoremote_one_55555555_11111111_light_function_3', + 'event.beoremote_one_55555555_11111111_light_function_4', + 'event.beoremote_one_55555555_11111111_light_function_5', + 'event.beoremote_one_55555555_11111111_light_function_6', + 'event.beoremote_one_55555555_11111111_light_function_7', + 'event.beoremote_one_55555555_11111111_light_function_8', + 'event.beoremote_one_55555555_11111111_light_function_9', + 'event.beoremote_one_55555555_11111111_light_green', + 'event.beoremote_one_55555555_11111111_light_left', + 'event.beoremote_one_55555555_11111111_light_play', + 'event.beoremote_one_55555555_11111111_light_red', + 'event.beoremote_one_55555555_11111111_light_rewind', + 'event.beoremote_one_55555555_11111111_light_right', + 'event.beoremote_one_55555555_11111111_light_select', + 'event.beoremote_one_55555555_11111111_light_stop', + 'event.beoremote_one_55555555_11111111_light_up', + 'event.beoremote_one_55555555_11111111_light_wind', + 'event.beoremote_one_55555555_11111111_light_yellow', 'event.beoremote_one_66666666_11111111_control_blue', 'event.beoremote_one_66666666_11111111_control_digit_0', 'event.beoremote_one_66666666_11111111_control_digit_1', @@ -261,26 +209,7 @@ 'event.beoremote_one_66666666_11111111_control_digit_8', 'event.beoremote_one_66666666_11111111_control_digit_9', 'event.beoremote_one_66666666_11111111_control_down', - 'event.beoremote_one_66666666_11111111_control_green', - 'event.beoremote_one_66666666_11111111_control_left', - 'event.beoremote_one_66666666_11111111_control_play', - 'event.beoremote_one_66666666_11111111_control_red', - 'event.beoremote_one_66666666_11111111_control_rewind', - 'event.beoremote_one_66666666_11111111_control_right', - 'event.beoremote_one_66666666_11111111_control_select', - 'event.beoremote_one_66666666_11111111_control_stop', - 'event.beoremote_one_66666666_11111111_control_up', - 'event.beoremote_one_66666666_11111111_control_wind', - 'event.beoremote_one_66666666_11111111_control_yellow', 'event.beoremote_one_66666666_11111111_control_function_1', - 'event.beoremote_one_66666666_11111111_control_function_2', - 'event.beoremote_one_66666666_11111111_control_function_3', - 'event.beoremote_one_66666666_11111111_control_function_4', - 'event.beoremote_one_66666666_11111111_control_function_5', - 'event.beoremote_one_66666666_11111111_control_function_6', - 'event.beoremote_one_66666666_11111111_control_function_7', - 'event.beoremote_one_66666666_11111111_control_function_8', - 'event.beoremote_one_66666666_11111111_control_function_9', 'event.beoremote_one_66666666_11111111_control_function_10', 'event.beoremote_one_66666666_11111111_control_function_11', 'event.beoremote_one_66666666_11111111_control_function_12', @@ -291,6 +220,7 @@ 'event.beoremote_one_66666666_11111111_control_function_17', 'event.beoremote_one_66666666_11111111_control_function_18', 'event.beoremote_one_66666666_11111111_control_function_19', + 'event.beoremote_one_66666666_11111111_control_function_2', 'event.beoremote_one_66666666_11111111_control_function_20', 'event.beoremote_one_66666666_11111111_control_function_21', 'event.beoremote_one_66666666_11111111_control_function_22', @@ -299,21 +229,91 @@ 'event.beoremote_one_66666666_11111111_control_function_25', 'event.beoremote_one_66666666_11111111_control_function_26', 'event.beoremote_one_66666666_11111111_control_function_27', + 'event.beoremote_one_66666666_11111111_control_function_3', + 'event.beoremote_one_66666666_11111111_control_function_4', + 'event.beoremote_one_66666666_11111111_control_function_5', + 'event.beoremote_one_66666666_11111111_control_function_6', + 'event.beoremote_one_66666666_11111111_control_function_7', + 'event.beoremote_one_66666666_11111111_control_function_8', + 'event.beoremote_one_66666666_11111111_control_function_9', + 'event.beoremote_one_66666666_11111111_control_green', + 'event.beoremote_one_66666666_11111111_control_left', + 'event.beoremote_one_66666666_11111111_control_play', + 'event.beoremote_one_66666666_11111111_control_red', + 'event.beoremote_one_66666666_11111111_control_rewind', + 'event.beoremote_one_66666666_11111111_control_right', + 'event.beoremote_one_66666666_11111111_control_select', + 'event.beoremote_one_66666666_11111111_control_stop', + 'event.beoremote_one_66666666_11111111_control_up', + 'event.beoremote_one_66666666_11111111_control_wind', + 'event.beoremote_one_66666666_11111111_control_yellow', + 'event.beoremote_one_66666666_11111111_light_blue', + 'event.beoremote_one_66666666_11111111_light_digit_0', + 'event.beoremote_one_66666666_11111111_light_digit_1', + 'event.beoremote_one_66666666_11111111_light_digit_2', + 'event.beoremote_one_66666666_11111111_light_digit_3', + 'event.beoremote_one_66666666_11111111_light_digit_4', + 'event.beoremote_one_66666666_11111111_light_digit_5', + 'event.beoremote_one_66666666_11111111_light_digit_6', + 'event.beoremote_one_66666666_11111111_light_digit_7', + 'event.beoremote_one_66666666_11111111_light_digit_8', + 'event.beoremote_one_66666666_11111111_light_digit_9', + 'event.beoremote_one_66666666_11111111_light_down', + 'event.beoremote_one_66666666_11111111_light_function_1', + 'event.beoremote_one_66666666_11111111_light_function_10', + 'event.beoremote_one_66666666_11111111_light_function_11', + 'event.beoremote_one_66666666_11111111_light_function_12', + 'event.beoremote_one_66666666_11111111_light_function_13', + 'event.beoremote_one_66666666_11111111_light_function_14', + 'event.beoremote_one_66666666_11111111_light_function_15', + 'event.beoremote_one_66666666_11111111_light_function_16', + 'event.beoremote_one_66666666_11111111_light_function_17', + 'event.beoremote_one_66666666_11111111_light_function_2', + 'event.beoremote_one_66666666_11111111_light_function_3', + 'event.beoremote_one_66666666_11111111_light_function_4', + 'event.beoremote_one_66666666_11111111_light_function_5', + 'event.beoremote_one_66666666_11111111_light_function_6', + 'event.beoremote_one_66666666_11111111_light_function_7', + 'event.beoremote_one_66666666_11111111_light_function_8', + 'event.beoremote_one_66666666_11111111_light_function_9', + 'event.beoremote_one_66666666_11111111_light_green', + 'event.beoremote_one_66666666_11111111_light_left', + 'event.beoremote_one_66666666_11111111_light_play', + 'event.beoremote_one_66666666_11111111_light_red', + 'event.beoremote_one_66666666_11111111_light_rewind', + 'event.beoremote_one_66666666_11111111_light_right', + 'event.beoremote_one_66666666_11111111_light_select', + 'event.beoremote_one_66666666_11111111_light_stop', + 'event.beoremote_one_66666666_11111111_light_up', + 'event.beoremote_one_66666666_11111111_light_wind', + 'event.beoremote_one_66666666_11111111_light_yellow', + 'event.living_room_balance_bluetooth', + 'event.living_room_balance_favorite_1', + 'event.living_room_balance_favorite_2', + 'event.living_room_balance_favorite_3', + 'event.living_room_balance_favorite_4', + 'event.living_room_balance_microphone', + 'event.living_room_balance_next', + 'event.living_room_balance_play_pause', + 'event.living_room_balance_previous', + 'event.living_room_balance_volume', + 'media_player.living_room_balance', + 'sensor.beoremote_one_55555555_11111111_battery', 'sensor.beoremote_one_66666666_11111111_battery', ]) # --- # name: test_on_remote_control_unpaired list([ - 'event.beosound_balance_11111111_bluetooth', - 'event.beosound_balance_11111111_microphone', - 'event.beosound_balance_11111111_next', - 'event.beosound_balance_11111111_play_pause', - 'event.beosound_balance_11111111_favorite_1', - 'event.beosound_balance_11111111_favorite_2', - 'event.beosound_balance_11111111_favorite_3', - 'event.beosound_balance_11111111_favorite_4', - 'event.beosound_balance_11111111_previous', - 'event.beosound_balance_11111111_volume', - 'media_player.beosound_balance_11111111', + 'event.living_room_balance_bluetooth', + 'event.living_room_balance_favorite_1', + 'event.living_room_balance_favorite_2', + 'event.living_room_balance_favorite_3', + 'event.living_room_balance_favorite_4', + 'event.living_room_balance_microphone', + 'event.living_room_balance_next', + 'event.living_room_balance_play_pause', + 'event.living_room_balance_previous', + 'event.living_room_balance_volume', + 'media_player.living_room_balance', ]) # --- diff --git a/tests/components/bang_olufsen/test_init.py b/tests/components/bang_olufsen/test_init.py index 9c9412a5627b8c..ac27618895634a 100644 --- a/tests/components/bang_olufsen/test_init.py +++ b/tests/components/bang_olufsen/test_init.py @@ -35,7 +35,7 @@ async def test_setup_entry( identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} ) assert device is not None - # Is usually TEST_NAME, but is updated to the device's friendly name by _update_name_and_beolink + # Device name is set from the config entry title (friendly name) assert device.name == TEST_FRIENDLY_NAME assert device.model == TEST_MODEL_BALANCE diff --git a/tests/components/bang_olufsen/test_websocket.py b/tests/components/bang_olufsen/test_websocket.py index fb53f9eef946ca..94d6d54a9e37ff 100644 --- a/tests/components/bang_olufsen/test_websocket.py +++ b/tests/components/bang_olufsen/test_websocket.py @@ -25,7 +25,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from .const import ( - TEST_NAME, + TEST_FRIENDLY_NAME, TEST_REMOTE_SERIAL, TEST_REMOTE_SERIAL_PAIRED, TEST_SERIAL_NUMBER, @@ -62,7 +62,7 @@ async def test_connection( await hass.async_block_till_done() mock_connection_callback.assert_called_once_with(True) - assert f"Connected to the {TEST_NAME} notification channel" in caplog.text + assert f"Connected to the {TEST_FRIENDLY_NAME} notification channel" in caplog.text async def test_connection_lost( @@ -87,7 +87,7 @@ async def test_connection_lost( await hass.async_block_till_done() mock_connection_lost_callback.assert_called_once_with(False) - assert f"Lost connection to the {TEST_NAME}" in caplog.text + assert f"Lost connection to the {TEST_FRIENDLY_NAME}" in caplog.text async def test_on_software_update_state( @@ -130,7 +130,7 @@ async def test_on_remote_control_already_added( await hass.config_entries.async_setup(mock_config_entry.entry_id) # Check device and API call count - assert mock_mozart_client.get_bluetooth_remotes.call_count == 3 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 4 assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) # Check number of entities (remote and button events and media_player) @@ -149,16 +149,16 @@ async def test_on_remote_control_already_added( await hass.async_block_till_done() # Check device and API call count (triggered once by the WebSocket notification) - assert mock_mozart_client.get_bluetooth_remotes.call_count == 4 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 5 assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) # Check number of entities (remote and button events and media_player) entity_ids_available = list(entity_registry.entities.keys()) - assert list(entity_registry.entities.keys()) == unordered( + assert entity_ids_available == unordered( [*get_balance_entity_ids(), *get_remote_entity_ids()] ) - assert entity_ids_available == snapshot + assert sorted(entity_ids_available) == snapshot async def test_on_remote_control_paired( @@ -176,7 +176,7 @@ async def test_on_remote_control_paired( await hass.config_entries.async_setup(mock_config_entry.entry_id) # Check device and API call count - assert mock_mozart_client.get_bluetooth_remotes.call_count == 3 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 4 assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) # Check number of entities (button and remote events and media_player) @@ -217,7 +217,7 @@ async def test_on_remote_control_paired( await hass.async_block_till_done() # Check device and API call count - assert mock_mozart_client.get_bluetooth_remotes.call_count == 8 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 10 assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) assert device_registry.async_get_device( {(DOMAIN, f"66666666_{TEST_SERIAL_NUMBER}")} @@ -239,7 +239,7 @@ async def test_on_remote_control_paired( *get_remote_entity_ids("66666666"), ] ) - assert entity_ids_available == snapshot + assert sorted(entity_ids_available) == snapshot async def test_on_remote_control_unpaired( @@ -257,7 +257,7 @@ async def test_on_remote_control_unpaired( await hass.config_entries.async_setup(mock_config_entry.entry_id) # Check device and API call count - assert mock_mozart_client.get_bluetooth_remotes.call_count == 3 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 4 assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) # Check number of entities (button and remote events and media_player) @@ -280,7 +280,7 @@ async def test_on_remote_control_unpaired( await hass.async_block_till_done() # Check device and API call count - assert mock_mozart_client.get_bluetooth_remotes.call_count == 6 + assert mock_mozart_client.get_bluetooth_remotes.call_count == 8 assert ( device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) is None ) @@ -295,7 +295,7 @@ async def test_on_remote_control_unpaired( entity_ids_available = list(entity_registry.entities.keys()) assert entity_ids_available == unordered(get_balance_entity_ids()) - assert entity_ids_available == snapshot + assert sorted(entity_ids_available) == snapshot # async def test_setup_entry_remote_unpaired( diff --git a/tests/components/bang_olufsen/util.py b/tests/components/bang_olufsen/util.py index caf1fe77352fda..e15c0e24484c10 100644 --- a/tests/components/bang_olufsen/util.py +++ b/tests/components/bang_olufsen/util.py @@ -22,7 +22,7 @@ ) -def _get_button_entity_ids(id_prefix: str = "beosound_balance_11111111") -> list[str]: +def _get_button_entity_ids(id_prefix: str = "living_room_balance") -> list[str]: """Return a list of button entity_ids that Mozart devices provide. Beoconnect Core, Beosound A5, Beosound A9 and Beosound Premiere do not have (all of the) physical buttons and need filtering. @@ -42,10 +42,10 @@ def get_premiere_entity_ids() -> list[str]: """Return a list of entity_ids that a Beosound Premiere provides.""" buttons = [ TEST_MEDIA_PLAYER_ENTITY_ID_3, - *_get_button_entity_ids("beosound_premiere_33333333"), + *_get_button_entity_ids("bedroom_premiere"), ] - buttons.remove("event.beosound_premiere_33333333_bluetooth") - buttons.remove("event.beosound_premiere_33333333_microphone") + buttons.remove("event.bedroom_premiere_bluetooth") + buttons.remove("event.bedroom_premiere_microphone") return buttons @@ -55,9 +55,9 @@ def get_a5_entity_ids() -> list[str]: TEST_MEDIA_PLAYER_ENTITY_ID_4, TEST_BATTERY_SENSOR_ENTITY_ID, TEST_BATTERY_CHARGING_BINARY_SENSOR_ENTITY_ID, - *_get_button_entity_ids("beosound_a5_44444444"), + *_get_button_entity_ids("lounge_room_a5"), ] - buttons.remove("event.beosound_a5_44444444_microphone") + buttons.remove("event.lounge_room_a5_microphone") return buttons diff --git a/tests/components/fumis/test_config_flow.py b/tests/components/fumis/test_config_flow.py index 6e6a0718310164..31f2c095d33cb0 100644 --- a/tests/components/fumis/test_config_flow.py +++ b/tests/components/fumis/test_config_flow.py @@ -300,3 +300,67 @@ async def test_dhcp_discovery_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_fumis") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_MAC] == "AABBCCDDEEFF" + assert mock_config_entry.data[CONF_PIN] == "5678" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (FumisAuthenticationError, {CONF_PIN: "invalid_auth"}), + (FumisStoveOfflineError, {"base": "device_offline"}), + (FumisConnectionError, {"base": "cannot_connect"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_fumis: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: type[Exception], + expected_error: dict[str, str], +) -> None: + """Test the reconfigure flow with errors.""" + mock_config_entry.add_to_hass(hass) + mock_fumis.update_info.side_effect = side_effect + + result = await mock_config_entry.start_reconfigure_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == expected_error + + mock_fumis.update_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/homee/fixtures/lock_with_open.json b/tests/components/homee/fixtures/lock_with_open.json new file mode 100644 index 00000000000000..e30c2b012c41ae --- /dev/null +++ b/tests/components/homee/fixtures/lock_with_open.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "name": "Test Lock", + "profile": 2007, + "image": "default", + "favorite": 0, + "order": 31, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1711799526, + "added": 1645036891, + "history": 1, + "cube_type": 1, + "note": "", + "services": 3, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 232, + "state": 1, + "last_changed": 1711897362, + "changed_by": 4, + "changed_by_id": 5, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/lock_with_unlatch.json b/tests/components/homee/fixtures/lock_with_unlatch.json new file mode 100644 index 00000000000000..b7c48a82c590da --- /dev/null +++ b/tests/components/homee/fixtures/lock_with_unlatch.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "name": "Test Lock", + "profile": 2007, + "image": "default", + "favorite": 0, + "order": 31, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1711799526, + "added": 1645036891, + "history": 1, + "cube_type": 1, + "note": "", + "services": 3, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": -1, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 232, + "state": 1, + "last_changed": 1711897362, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "", + "options": { + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/test_lock.py b/tests/components/homee/test_lock.py index 416da84140703a..e925bb4c4116ef 100644 --- a/tests/components/homee/test_lock.py +++ b/tests/components/homee/test_lock.py @@ -9,6 +9,7 @@ from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, + SERVICE_OPEN, SERVICE_UNLOCK, LockState, ) @@ -29,10 +30,13 @@ async def platforms() -> AsyncGenerator[None]: async def setup_lock( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homee: MagicMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + fixture: str = "lock.json", ) -> None: """Setups the integration lock tests.""" - mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.nodes = [build_mock_node(fixture)] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) @@ -58,10 +62,42 @@ async def test_lock_services( LOCK_DOMAIN, service, {ATTR_ENTITY_ID: "lock.test_lock"}, + blocking=True, ) mock_homee.set_value.assert_called_once_with(1, 1, target_value) +@pytest.mark.parametrize( + ("fixture", "open_value"), + [ + ("lock_with_open.json", 2.0), + ("lock_with_unlatch.json", -1.0), + ], +) +async def test_lock_open_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + fixture: str, + open_value: float, +) -> None: + """Test the open service on locks that support momentary unlatch. + + Different homee-compatible devices encode the unlatch command + differently — a positive extension (value 2) or a signed range + where -1 is unlatch (e.g. the Hörmann SmartKey). + """ + await setup_lock(hass, mock_config_entry, mock_homee, fixture) + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + {ATTR_ENTITY_ID: "lock.test_lock"}, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 1, open_value) + + @pytest.mark.parametrize( ("target_value", "current_value", "expected"), [ @@ -90,6 +126,44 @@ async def test_lock_state( assert hass.states.get("lock.test_lock").state == expected +@pytest.mark.parametrize( + ("fixture", "open_value"), + [ + ("lock_with_open.json", 2.0), + ("lock_with_unlatch.json", -1.0), + ], +) +@pytest.mark.parametrize( + ("target_offset", "current_offset", "expected"), + [ + ("open", "open", LockState.OPEN), + ("open", 0.0, LockState.OPENING), + ("open", 1.0, LockState.OPENING), + (1.0, "open", LockState.LOCKING), + (0.0, "open", LockState.UNLOCKING), + ], +) +async def test_lock_state_with_open( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + fixture: str, + open_value: float, + target_offset: float | str, + current_offset: float | str, + expected: LockState, +) -> None: + """Test lock state transitions that involve the open value.""" + mock_homee.nodes = [build_mock_node(fixture)] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + attribute = mock_homee.nodes[0].attributes[0] + attribute.target_value = open_value if target_offset == "open" else target_offset + attribute.current_value = open_value if current_offset == "open" else current_offset + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.test_lock").state == expected + + @pytest.mark.parametrize( ("attr_changed_by", "changed_by_id", "expected"), [ diff --git a/tests/components/image_upload/test_media_source.py b/tests/components/image_upload/test_media_source.py index 9e76a67da8a01f..cf2be99958f71e 100644 --- a/tests/components/image_upload/test_media_source.py +++ b/tests/components/image_upload/test_media_source.py @@ -56,7 +56,7 @@ async def test_browsing( item = await media_source.async_browse_media(hass, "media-source://image_upload") assert item is not None - assert item.title == "Image Upload" + assert item.title == "Image upload" assert len(item.children) == 1 assert item.children[0].media_content_type == "image/png" assert item.children[0].identifier == image_id diff --git a/tests/components/mqtt/test_time.py b/tests/components/mqtt/test_time.py new file mode 100644 index 00000000000000..193c03a0b380b1 --- /dev/null +++ b/tests/components/mqtt/test_time.py @@ -0,0 +1,591 @@ +"""The tests for the MQTT time platform.""" + +from __future__ import annotations + +import datetime +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, time +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import ( + help_custom_config, + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: {time.DOMAIN: {"name": "test", "command_topic": "test-topic"}} +} + + +async def async_set_value( + hass: HomeAssistant, entity_id: str, value: datetime.time | None +) -> None: + """Set time value.""" + await hass.services.async_call( + time.DOMAIN, + time.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, "time": value}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + time.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry() + + state = hass.states.get("time.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", "10:34") + state = hass.states.get("time.test") + assert state.state == "10:34:00" + + async_fire_mqtt_message(hass, "state-topic", "1:45 PM") + state = hass.states.get("time.test") + assert state.state == "13:45:00" + + async_fire_mqtt_message(hass, "state-topic", "None") + state = hass.states.get("time.test") + assert state.state == STATE_UNKNOWN + + # Empty string should be ignored + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "") + assert "Ignoring empty state payload" in caplog.text + + state = hass.states.get("time.test") + assert state.state == STATE_UNKNOWN + + # Invalid value should show warning + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "No valid time") + assert "Invalid received time expression" in caplog.text + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + time.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + ], +) +@pytest.mark.parametrize( + ("received_state", "expected_state"), + [ + ("00:00", "00:00:00"), + ("00:00 AM", "00:00:00"), + ("00:00 PM", "12:00:00"), + ], +) +async def test_controlling_validation_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + received_state: str, + expected_state: str, +) -> None: + """Test the validation of a received state.""" + await mqtt_mock_entry() + + state = hass.states.get("time.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "state-topic", received_state) + state = hass.states.get("time.test") + assert state.state == expected_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + time.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "qos": "2", + } + } + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("time.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_value(hass, "time.test", datetime.time(hour=10, minute=12)) + await hass.async_block_till_done() + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "10:12:00", 2, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("time.test") + assert state.state == "10:12:00" + + await async_set_value( + hass, "time.test", datetime.time(hour=10, minute=12, second=1, microsecond=12) + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "10:12:01.000012", 2, False + ) + state = hass.states.get("time.test") + assert state.state == "10:12:01.000012" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, time.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + config = { + mqtt.DOMAIN: { + time.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + await help_test_default_availability_payload( + hass, mqtt_mock_entry, time.DOMAIN, config, True, "state-topic", "10:12" + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + config = { + mqtt.DOMAIN: { + time.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, time.DOMAIN, config, True, "state-topic", "10:12" + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry, caplog, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry, caplog, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + time.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one time per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, time.DOMAIN) + + +async def test_discovery_removal_time( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test removal of discovered time entity.""" + data = ( + '{ "name": "test",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + await help_test_discovery_removal(hass, mqtt_mock_entry, time.DOMAIN, data) + + +async def test_discovery_time_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered time entity.""" + config1 = { + "name": "Beer", + "command_topic": "command-topic", + "state_topic": "state-topic", + } + config2 = { + "name": "Milk", + "command_topic": "command-topic", + "state_topic": "state-topic", + } + + await help_test_discovery_update( + hass, mqtt_mock_entry, time.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered update.""" + data1 = '{ "name": "Beer", "state_topic": "time-topic", "command_topic": "command-topic"}' + with patch( + "homeassistant.components.mqtt.time.MqttTimeEntity.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock_entry, time.DOMAIN, data1, discovery_update + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + await help_test_discovery_broken(hass, mqtt_mock_entry, time.DOMAIN, data1, data2) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT time device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT time device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, mqtt_mock_entry, time.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_reloadable( + hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient +) -> None: + """Test reloading the MQTT platform.""" + domain = time.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("state_topic", "10:12:00", None, "10:12:00"), + ], +) +async def test_encoding_subscribable_topics( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, +) -> None: + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + time.DOMAIN, + DEFAULT_CONFIG[mqtt.DOMAIN][time.DOMAIN], + topic, + value, + attribute, + attribute_value, + ) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = time.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unloading the config entry.""" + domain = time.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + time.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "10:12", "10:13"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + time.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "value_template": "{{ value_json.some_var * 1 }}", + }, + ), + ) + ], +) +async def test_value_template_fails( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the rendering of MQTT value template fails.""" + await mqtt_mock_entry() + async_fire_mqtt_message(hass, "test-topic", '{"some_var": null }') + assert ( + "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" + in caplog.text + ) diff --git a/tests/components/nobo_hub/__init__.py b/tests/components/nobo_hub/__init__.py index 023f53bf6ee1f6..24f63401951b5a 100644 --- a/tests/components/nobo_hub/__init__.py +++ b/tests/components/nobo_hub/__init__.py @@ -1 +1,12 @@ """Tests for the Nobø Ecohub integration.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + + +async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: + """Fire the hub's registered push-update callbacks and wait for state to settle.""" + for call in hub.register_callback.call_args_list: + call.args[0](hub) + await hass.async_block_till_done() diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index 06c3351d2dedcd..ba31d95400a0a2 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -1,10 +1,25 @@ """Common fixtures for the Nobø Ecohub tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from pynobo import nobo as pynobo_nobo import pytest +from homeassistant.components.nobo_hub import PLATFORMS +from homeassistant.components.nobo_hub.const import ( + CONF_AUTO_DISCOVERED, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.const import CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +SERIAL = "102000013098" +STORED_IP = "192.168.1.122" + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -22,3 +37,122 @@ def mock_unload_entry() -> Generator[AsyncMock]: "homeassistant.components.nobo_hub.async_unload_entry", return_value=True ) as mock_unload_entry: yield mock_unload_entry + + +@pytest.fixture +def ip_address() -> str: + """Return the stored IP address for the config entry.""" + return STORED_IP + + +@pytest.fixture +def auto_discovered() -> bool: + """Return whether the config entry was auto-discovered.""" + return False + + +@pytest.fixture +def connect_exc() -> BaseException | None: + """Exception to raise from hub.connect(), or None for success.""" + return None + + +@pytest.fixture +def mock_config_entry(ip_address: str, auto_discovered: bool) -> MockConfigEntry: + """Return a mock Nobø Ecohub config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="My Eco Hub", + unique_id=SERIAL, + data={ + CONF_SERIAL: SERIAL, + CONF_IP_ADDRESS: ip_address, + CONF_AUTO_DISCOVERED: auto_discovered, + }, + ) + + +@pytest.fixture +def mock_nobo_class( + connect_exc: BaseException | None, +) -> Generator[MagicMock]: + """Patch the integration's imported `nobo` class with a populated hub instance.""" + with patch("homeassistant.components.nobo_hub.nobo", autospec=True) as mock_cls: + hub = mock_cls.return_value + if connect_exc is not None: + hub.connect.side_effect = connect_exc + + hub.hub_serial = SERIAL + hub.hub_info = { + "name": "My Eco Hub", + "serial": SERIAL, + "software_version": "115", + "hardware_version": "hw", + } + hub.zones = { + "1": { + "zone_id": "1", + "name": "Living room", + "week_profile_id": "0", + "temp_comfort_c": "21", + "temp_eco_c": "17", + }, + } + model = MagicMock() + # Direct assignment overrides MagicMock's auto-attr for `.name`. + model.name = "Panel heater" + model.has_temp_sensor = True + hub.components = { + "200000059091": { + "serial": "200000059091", + "name": "Floor sensor", + "zone_id": "1", + "model": model, + }, + } + hub.week_profiles = { + "0": {"week_profile_id": "0", "name": "Default", "profile": "00000"}, + } + hub.overrides = { + "988": { + "mode": pynobo_nobo.API.OVERRIDE_MODE_NORMAL, + "target_type": pynobo_nobo.API.OVERRIDE_TARGET_GLOBAL, + "target_id": "-1", + }, + } + hub.temperatures = {"200000059091": "21.5"} + + hub.get_current_zone_mode.return_value = pynobo_nobo.API.NAME_COMFORT + hub.get_zone_override_mode.return_value = pynobo_nobo.API.NAME_NORMAL + hub.get_current_zone_temperature.return_value = "20.5" + hub.get_current_component_temperature.return_value = "21.5" + + mock_cls.async_discover_hubs.return_value = set() + yield mock_cls + + +@pytest.fixture +def mock_nobo_hub(mock_nobo_class: MagicMock) -> MagicMock: + """Return the pre-configured pynobo hub instance.""" + return mock_nobo_class.return_value + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return the platforms to set up (default: the integration's full list).""" + return PLATFORMS + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_class: MagicMock, + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the Nobø Ecohub integration.""" + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.nobo_hub.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/nobo_hub/snapshots/test_select.ambr b/tests/components/nobo_hub/snapshots/test_select.ambr new file mode 100644 index 00000000000000..c7bb3d91de54c8 --- /dev/null +++ b/tests/components/nobo_hub/snapshots/test_select.ambr @@ -0,0 +1,122 @@ +# serializer version: 1 +# name: test_select_entities[select.living_room_week_profile-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Default', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.living_room_week_profile', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Week profile', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Week profile', + 'platform': 'nobo_hub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'week_profile', + 'unique_id': '102000013098:1:profile', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities[select.living_room_week_profile-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room Week profile', + 'options': list([ + 'Default', + ]), + }), + 'context': , + 'entity_id': 'select.living_room_week_profile', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Default', + }) +# --- +# name: test_select_entities[select.my_eco_hub_global_override-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'away', + 'comfort', + 'eco', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.my_eco_hub_global_override', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Global override', + 'options': dict({ + }), + 'original_device_class': 'nobo_hub__override', + 'original_icon': None, + 'original_name': 'Global override', + 'platform': 'nobo_hub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'global_override', + 'unique_id': '102000013098', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities[select.my_eco_hub_global_override-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'nobo_hub__override', + 'friendly_name': 'My Eco Hub Global override', + 'options': list([ + 'none', + 'away', + 'comfort', + 'eco', + ]), + }), + 'context': , + 'entity_id': 'select.my_eco_hub_global_override', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index c81eb3ec0027d0..b4d54485e97b71 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -1,7 +1,8 @@ """Tests for the Nobø Ecohub integration setup.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch +from pynobo import nobo as pynobo_nobo import pytest from homeassistant.components.nobo_hub import async_setup_entry @@ -17,42 +18,18 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from .conftest import SERIAL, STORED_IP + from tests.common import MockConfigEntry -SERIAL = "102000013098" -STORED_IP = "192.168.1.122" NEW_IP = "192.168.1.55" -def _make_entry( - hass: HomeAssistant, - *, - auto_discovered: bool, - ip_address: str = STORED_IP, -) -> MockConfigEntry: - """Create a mock config entry for Nobø Ecohub.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="My Eco Hub", - unique_id=SERIAL, - data={ - CONF_SERIAL: SERIAL, - CONF_IP_ADDRESS: ip_address, - CONF_AUTO_DISCOVERED: auto_discovered, - }, - ) - entry.add_to_hass(hass) - return entry - - -def _make_hub_mock(connect_exc: BaseException | None = None) -> MagicMock: - """Create a mock pynobo.nobo instance.""" - hub = MagicMock() - hub.connect = AsyncMock(side_effect=connect_exc) - hub.start = AsyncMock() - hub.stop = AsyncMock() - hub.register_callback = MagicMock() - hub.deregister_callback = MagicMock() +def _spec_hub(connect_exc: BaseException | None = None) -> MagicMock: + """Build a minimal spec'd pynobo hub for rediscovery tests.""" + hub = MagicMock(spec=pynobo_nobo) + if connect_exc is not None: + hub.connect.side_effect = connect_exc hub.hub_serial = SERIAL hub.hub_info = { "name": "My Eco Hub", @@ -62,39 +39,40 @@ def _make_hub_mock(connect_exc: BaseException | None = None) -> MagicMock: } hub.zones = {} hub.components = {} - hub.overrides = {} hub.week_profiles = {} + hub.overrides = {} return hub -async def test_setup_manual_entry_uses_stored_ip(hass: HomeAssistant) -> None: +async def test_setup_manual_entry_uses_stored_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_class: MagicMock, +) -> None: """Manual entry connects using the stored IP without rediscovery.""" - entry = _make_entry(hass, auto_discovered=False) - hub = _make_hub_mock() - with patch("homeassistant.components.nobo_hub.nobo") as mock_cls: - mock_cls.return_value = hub - mock_cls.async_discover_hubs = AsyncMock(return_value=set()) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED - assert mock_cls.call_args.kwargs["ip"] == STORED_IP - assert mock_cls.call_args.kwargs["discover"] is False - mock_cls.async_discover_hubs.assert_not_called() + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_nobo_class.call_args.kwargs["ip"] == STORED_IP + assert mock_nobo_class.call_args.kwargs["discover"] is False + mock_nobo_class.async_discover_hubs.assert_not_called() -async def test_setup_autodiscovered_entry_uses_stored_ip(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("auto_discovered", [True]) +async def test_setup_autodiscovered_entry_uses_stored_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_class: MagicMock, +) -> None: """Auto-discovered entry with a working stored IP does not rediscover.""" - entry = _make_entry(hass, auto_discovered=True) - hub = _make_hub_mock() - with patch("homeassistant.components.nobo_hub.nobo") as mock_cls: - mock_cls.return_value = hub - mock_cls.async_discover_hubs = AsyncMock(return_value=set()) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED - mock_cls.async_discover_hubs.assert_not_called() + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_nobo_class.async_discover_hubs.assert_not_called() @pytest.mark.parametrize( @@ -103,38 +81,40 @@ async def test_setup_autodiscovered_entry_uses_stored_ip(hass: HomeAssistant) -> ) async def test_setup_manual_entry_connection_fails( hass: HomeAssistant, - connect_exc: BaseException, + mock_config_entry: MockConfigEntry, + mock_nobo_class: MagicMock, ) -> None: """Manual entry raises ConfigEntryNotReady on socket errors or timeouts.""" - entry = _make_entry(hass, auto_discovered=False) - hub = _make_hub_mock(connect_exc=connect_exc) - with patch("homeassistant.components.nobo_hub.nobo") as mock_cls: - mock_cls.return_value = hub - mock_cls.async_discover_hubs = AsyncMock(return_value=set()) - with pytest.raises(ConfigEntryNotReady) as exc_info: - await async_setup_entry(hass, entry) + mock_config_entry.add_to_hass(hass) + with pytest.raises(ConfigEntryNotReady) as exc_info: + await async_setup_entry(hass, mock_config_entry) assert exc_info.value.translation_key == "cannot_connect_manual" assert exc_info.value.translation_placeholders == { "serial": SERIAL, "ip": STORED_IP, } - mock_cls.async_discover_hubs.assert_not_called() + mock_nobo_class.async_discover_hubs.assert_not_called() -async def test_setup_autodiscovered_rediscovery_updates_ip(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("auto_discovered", [True]) +async def test_setup_autodiscovered_rediscovery_updates_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: """Auto-discovered entry recovers via rediscovery and persists the new IP.""" - entry = _make_entry(hass, auto_discovered=True) - hub_fail = _make_hub_mock(connect_exc=OSError("Unreachable")) - hub_ok = _make_hub_mock() - with patch("homeassistant.components.nobo_hub.nobo") as mock_cls: - mock_cls.side_effect = [hub_fail, hub_ok] - mock_cls.async_discover_hubs = AsyncMock(return_value={(NEW_IP, SERIAL)}) - assert await hass.config_entries.async_setup(entry.entry_id) + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.nobo_hub.nobo", autospec=True) as mock_cls: + mock_cls.side_effect = [ + _spec_hub(connect_exc=OSError("Unreachable")), + _spec_hub(), + ] + mock_cls.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED - assert entry.data[CONF_IP_ADDRESS] == NEW_IP + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.data[CONF_IP_ADDRESS] == NEW_IP assert mock_cls.call_count == 2 assert mock_cls.call_args_list[0].kwargs["ip"] == STORED_IP assert mock_cls.call_args_list[1].kwargs["ip"] == NEW_IP @@ -146,31 +126,33 @@ async def test_setup_autodiscovered_rediscovery_updates_ip(hass: HomeAssistant) "rediscovered_connect_fails", "expected_key", "expected_placeholders", + "auto_discovered", ), [ - (set(), False, "hub_not_found", {"serial": SERIAL}), - ({(NEW_IP, SERIAL)}, True, "cannot_connect_rediscovered", {"ip": NEW_IP}), + (set(), False, "hub_not_found", {"serial": SERIAL}, True), + ({(NEW_IP, SERIAL)}, True, "cannot_connect_rediscovered", {"ip": NEW_IP}, True), ], ids=["rediscovery_empty", "rediscovered_ip_fails"], ) async def test_setup_autodiscovered_rediscovery_failure( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, discovered_hubs: set[tuple[str, str]], rediscovered_connect_fails: bool, expected_key: str, expected_placeholders: dict[str, str], ) -> None: """Auto-discovered entry raises the right error when rediscovery can't recover.""" - entry = _make_entry(hass, auto_discovered=True) - hub_first = _make_hub_mock(connect_exc=OSError("Unreachable")) - hub_second = _make_hub_mock( - connect_exc=OSError("Unreachable") if rediscovered_connect_fails else None - ) - with patch("homeassistant.components.nobo_hub.nobo") as mock_cls: - mock_cls.side_effect = [hub_first, hub_second] - mock_cls.async_discover_hubs = AsyncMock(return_value=discovered_hubs) + mock_config_entry.add_to_hass(hass) + second_exc = OSError("Unreachable") if rediscovered_connect_fails else None + with patch("homeassistant.components.nobo_hub.nobo", autospec=True) as mock_cls: + mock_cls.side_effect = [ + _spec_hub(connect_exc=OSError("Unreachable")), + _spec_hub(connect_exc=second_exc), + ] + mock_cls.async_discover_hubs.return_value = discovered_hubs with pytest.raises(ConfigEntryNotReady) as exc_info: - await async_setup_entry(hass, entry) + await async_setup_entry(hass, mock_config_entry) assert exc_info.value.translation_key == expected_key assert exc_info.value.translation_placeholders == expected_placeholders @@ -186,6 +168,7 @@ async def test_setup_autodiscovered_rediscovery_failure( ) async def test_migrate_options_lowercases_override_type( hass: HomeAssistant, + mock_nobo_class: MagicMock, stored_value: str, expected_value: str, ) -> None: @@ -204,18 +187,17 @@ async def test_migrate_options_lowercases_override_type( minor_version=1, ) entry.add_to_hass(hass) - hub = _make_hub_mock() - with patch("homeassistant.components.nobo_hub.nobo") as mock_cls: - mock_cls.return_value = hub - mock_cls.async_discover_hubs = AsyncMock(return_value=set()) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert entry.minor_version == 2 assert entry.options == {CONF_OVERRIDE_TYPE: expected_value} -async def test_migrate_options_without_override_type(hass: HomeAssistant) -> None: +async def test_migrate_options_without_override_type( + hass: HomeAssistant, + mock_nobo_class: MagicMock, +) -> None: """Migration still bumps the version when no override_type is stored.""" entry = MockConfigEntry( domain=DOMAIN, @@ -230,12 +212,8 @@ async def test_migrate_options_without_override_type(hass: HomeAssistant) -> Non minor_version=1, ) entry.add_to_hass(hass) - hub = _make_hub_mock() - with patch("homeassistant.components.nobo_hub.nobo") as mock_cls: - mock_cls.return_value = hub - mock_cls.async_discover_hubs = AsyncMock(return_value=set()) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert entry.minor_version == 2 assert entry.options == {} @@ -244,19 +222,17 @@ async def test_migrate_options_without_override_type(hass: HomeAssistant) -> Non async def test_setup_registers_hub_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_nobo_class: MagicMock, ) -> None: """The hub device is registered with the expected metadata.""" - entry = _make_entry(hass, auto_discovered=False) - hub = _make_hub_mock() - with patch("homeassistant.components.nobo_hub.nobo") as mock_cls: - mock_cls.return_value = hub - mock_cls.async_discover_hubs = AsyncMock(return_value=set()) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)}) assert device is not None - assert device.config_entries == {entry.entry_id} + assert device.config_entries == {mock_config_entry.entry_id} assert device.name == "My Eco Hub" assert device.manufacturer == "Glen Dimplex Nordic AS" assert device.model == "Nobø Ecohub" diff --git a/tests/components/nobo_hub/test_select.py b/tests/components/nobo_hub/test_select.py new file mode 100644 index 00000000000000..774ebb5f116f77 --- /dev/null +++ b/tests/components/nobo_hub/test_select.py @@ -0,0 +1,138 @@ +"""Tests for the Nobø Ecohub select platform.""" + +from unittest.mock import MagicMock + +from pynobo import nobo +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import fire_hub_update + +from tests.common import MockConfigEntry, snapshot_platform + +GLOBAL_ENTITY = "select.my_eco_hub_global_override" +PROFILE_ENTITY = "select.living_room_week_profile" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only set up the select platform for these tests.""" + return [Platform.SELECT] + + +@pytest.mark.usefixtures("init_integration") +async def test_select_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """All select entities match their snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_global_override_select_away( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Selecting 'away' on the global override applies the away override.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: GLOBAL_ENTITY, ATTR_OPTION: "away"}, + blocking=True, + ) + mock_nobo_hub.async_create_override.assert_called_once_with( + nobo.API.OVERRIDE_MODE_AWAY, + nobo.API.OVERRIDE_TYPE_CONSTANT, + nobo.API.OVERRIDE_TARGET_GLOBAL, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_week_profile_select( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Selecting a week profile updates the zone.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: PROFILE_ENTITY, ATTR_OPTION: "Default"}, + blocking=True, + ) + mock_nobo_hub.async_update_zone.assert_called_once_with("1", week_profile_id="0") + + +@pytest.mark.parametrize( + ("entity_id", "option", "mock_attr"), + [ + (GLOBAL_ENTITY, "eco", "async_create_override"), + (PROFILE_ENTITY, "Default", "async_update_zone"), + ], + ids=["global_override", "week_profile"], +) +@pytest.mark.usefixtures("init_integration") +async def test_select_option_wraps_library_error( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_id: str, + option: str, + mock_attr: str, +) -> None: + """Library errors during selection are raised as HomeAssistantError.""" + getattr(mock_nobo_hub, mock_attr).side_effect = OSError("boom") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option}, + blocking=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_global_override_push_update( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Pushed hub updates refresh the global override state.""" + assert hass.states.get(GLOBAL_ENTITY).state == "none" + + mock_nobo_hub.overrides = { + "988": { + "mode": nobo.API.OVERRIDE_MODE_COMFORT, + "target_type": nobo.API.OVERRIDE_TARGET_GLOBAL, + "target_id": "-1", + }, + } + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(GLOBAL_ENTITY).state == "comfort" + + +@pytest.mark.usefixtures("init_integration") +async def test_week_profile_push_update( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Pushed hub updates refresh the week profile state.""" + assert hass.states.get(PROFILE_ENTITY).state == "Default" + + mock_nobo_hub.week_profiles = { + "0": {"week_profile_id": "0", "name": "Default", "profile": "00000"}, + "1": {"week_profile_id": "1", "name": "Weekend", "profile": "00001"}, + } + mock_nobo_hub.zones["1"]["week_profile_id"] = "1" + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(PROFILE_ENTITY).state == "Weekend" diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index ba0a541e75a898..dc35a2d803bc97 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -462,7 +462,7 @@ 'platform': 'overkiz', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'io://1234-5678-5010/2150846', 'unit_of_measurement': None, @@ -471,11 +471,12 @@ # name: test_cover_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][cover.basement_roller_shutter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': 90, + 'current_position': 80, + 'current_tilt_position': 100, 'device_class': 'shutter', 'friendly_name': 'Basement Roller Shutter', 'is_closed': False, - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'cover.basement_roller_shutter', @@ -840,7 +841,7 @@ 'platform': 'overkiz', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'io://1234-5678-5010/12931361', 'unit_of_measurement': None, @@ -849,18 +850,19 @@ # name: test_cover_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][cover.veranda_roller_shutter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': 50, + 'current_position': 0, + 'current_tilt_position': 100, 'device_class': 'shutter', 'friendly_name': 'Veranda Roller Shutter', - 'is_closed': False, - 'supported_features': , + 'is_closed': True, + 'supported_features': , }), 'context': , 'entity_id': 'cover.veranda_roller_shutter', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'open', + 'state': 'closed', }) # --- # name: test_cover_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][cover.workshop_screen-entry] @@ -1119,7 +1121,6 @@ # name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.garden_gate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': 50, 'device_class': 'gate', 'friendly_name': 'Garden Gate', 'is_closed': True, @@ -1767,7 +1768,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.bathroom_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 68, 'device_class': 'blind', 'friendly_name': 'Bathroom Blinds', @@ -1779,7 +1780,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.bedroom_blinds-entry] @@ -1822,7 +1823,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.bedroom_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 68, 'device_class': 'blind', 'friendly_name': 'Bedroom Blinds', @@ -1834,7 +1835,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.dining_room_blinds-entry] @@ -1932,7 +1933,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.garage_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 69, 'device_class': 'blind', 'friendly_name': 'Garage Blinds', @@ -1944,7 +1945,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.guest_room_blinds-entry] @@ -1987,7 +1988,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.guest_room_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 68, 'device_class': 'blind', 'friendly_name': 'Guest Room Blinds', @@ -1999,7 +2000,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.hallway_blinds-entry] @@ -2042,7 +2043,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.hallway_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 68, 'device_class': 'blind', 'friendly_name': 'Hallway Blinds', @@ -2054,7 +2055,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.kitchen_blinds-entry] @@ -2097,7 +2098,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.kitchen_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 68, 'device_class': 'blind', 'friendly_name': 'Kitchen Blinds', @@ -2109,7 +2110,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.living_room_blinds-entry] @@ -2152,7 +2153,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.living_room_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 68, 'device_class': 'blind', 'friendly_name': 'Living Room Blinds', @@ -2164,7 +2165,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.master_bedroom_blinds-entry] @@ -2207,7 +2208,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.master_bedroom_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 68, 'device_class': 'blind', 'friendly_name': 'Master Bedroom Blinds', @@ -2219,7 +2220,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.nursery_blinds-entry] @@ -2262,7 +2263,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.nursery_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 69, 'device_class': 'blind', 'friendly_name': 'Nursery Blinds', @@ -2274,7 +2275,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.office_blinds-entry] @@ -2317,7 +2318,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.office_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 68, 'device_class': 'blind', 'friendly_name': 'Office Blinds', @@ -2329,7 +2330,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.study_blinds-entry] @@ -2372,7 +2373,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe.json][cover.study_blinds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -8, + 'current_position': 0, 'current_tilt_position': 72, 'device_class': 'blind', 'friendly_name': 'Study Blinds', @@ -2384,7 +2385,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe_2.json][cover.back_door_shutter-entry] @@ -2427,7 +2428,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe_2.json][cover.back_door_shutter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -24, + 'current_position': 100, 'device_class': 'shutter', 'friendly_name': 'Back Door Shutter', 'is_closed': False, @@ -2438,7 +2439,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe_2.json][cover.front_door_shutter-entry] @@ -2481,7 +2482,7 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe_2.json][cover.front_door_shutter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_position': -24, + 'current_position': 100, 'device_class': 'shutter', 'friendly_name': 'Front Door Shutter', 'is_closed': False, @@ -2492,7 +2493,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'open', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe_2.json][cover.roof_window-entry] diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 061d7b64f09cce..caaf0dfdce31cc 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -133,24 +133,10 @@ async def test_cover_entities_snapshot( ("device", "service", "command_name", "expected_state"), [ (SHUTTER, SERVICE_OPEN_COVER, "open", CoverState.OPENING), - pytest.param( - AWNING, - SERVICE_OPEN_COVER, - "deploy", - CoverState.OPENING, - marks=pytest.mark.xfail(reason="Awning deploy not mapped to opening state"), - ), + (AWNING, SERVICE_OPEN_COVER, "deploy", CoverState.OPENING), (GARAGE, SERVICE_OPEN_COVER, "open", CoverState.OPENING), (SHUTTER, SERVICE_CLOSE_COVER, "close", CoverState.CLOSING), - pytest.param( - AWNING, - SERVICE_CLOSE_COVER, - "undeploy", - CoverState.CLOSING, - marks=pytest.mark.xfail( - reason="Awning undeploy not mapped to closing state" - ), - ), + (AWNING, SERVICE_CLOSE_COVER, "undeploy", CoverState.CLOSING), (GARAGE, SERVICE_CLOSE_COVER, "close", CoverState.CLOSING), (SHUTTER, SERVICE_STOP_COVER, "stop", CoverState.CLOSED), (AWNING, SERVICE_STOP_COVER, "stop", CoverState.CLOSED), @@ -668,3 +654,151 @@ async def test_awning_direct_position_mapping( ], ) assert hass.states.get(AWNING.entity_id).attributes[ATTR_CURRENT_POSITION] == 100 + + +async def test_moving_offset_missing_closure_states( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that is_opening/is_closing return None when closure states are missing while moving.""" + await setup_overkiz_integration(fixture=PERGOLA.fixture) + + await async_deliver_events( + hass, + freezer, + mock_client, + [ + build_event( + EventName.DEVICE_STATE_CHANGED.value, + device_url=PERGOLA.device_url, + device_states=[ + { + "name": OverkizState.CORE_MOVING.value, + "type": 6, + "value": True, + }, + ], + ) + ], + ) + + state = hass.states.get(PERGOLA.entity_id) + assert state.state == CoverState.CLOSED + + +async def test_moving_offset_none_values( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that is_opening/is_closing return None when closure value_as_int is None.""" + await setup_overkiz_integration(fixture=SHUTTER.fixture) + + await async_deliver_events( + hass, + freezer, + mock_client, + [ + build_event( + EventName.DEVICE_STATE_CHANGED.value, + device_url=SHUTTER.device_url, + device_states=[ + { + "name": OverkizState.CORE_MOVING.value, + "type": 6, + "value": True, + }, + { + "name": OverkizState.CORE_CLOSURE.value, + "type": 1, + "value": None, + }, + { + "name": OverkizState.CORE_TARGET_CLOSURE.value, + "type": 1, + "value": 50, + }, + { + "name": OverkizState.CORE_OPEN_CLOSED.value, + "type": 3, + "value": OverkizCommandParam.OPEN.value, + }, + ], + ) + ], + ) + + state = hass.states.get(SHUTTER.entity_id) + assert state.state == CoverState.OPEN + + +async def test_tilt_position_none_value( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that tilt position returns None when value_as_int is None.""" + await setup_overkiz_integration(fixture=PERGOLA.fixture) + + await async_deliver_events( + hass, + freezer, + mock_client, + [ + build_event( + EventName.DEVICE_STATE_CHANGED.value, + device_url=PERGOLA.device_url, + device_states=[ + { + "name": OverkizState.CORE_SLATE_ORIENTATION.value, + "type": 1, + "value": None, + }, + ], + ) + ], + ) + + state = hass.states.get(PERGOLA.entity_id) + assert ATTR_CURRENT_TILT_POSITION not in state.attributes + + +async def test_low_speed_cover_open_close( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, +) -> None: + """Test low speed cover open and close send correct commands.""" + await setup_overkiz_integration(fixture=LOW_SPEED.fixture) + entity_id = "cover.nursery_shutter_low_speed" + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert_command_call( + mock_client, + device_url=LOW_SPEED.device_url, + command_name="setClosureAndLinearSpeed", + parameters=[0, OverkizCommandParam.LOWSPEED], + ) + + mock_client.execute_command.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert_command_call( + mock_client, + device_url=LOW_SPEED.device_url, + command_name="setClosureAndLinearSpeed", + parameters=[100, OverkizCommandParam.LOWSPEED], + ) diff --git a/tests/components/satel_integra/__init__.py b/tests/components/satel_integra/__init__.py index 78de1cee3fdc99..47511eaefb6f2d 100644 --- a/tests/components/satel_integra/__init__.py +++ b/tests/components/satel_integra/__init__.py @@ -115,3 +115,15 @@ def get_monitor_callbacks( partitions_cb, zones_cb, outputs_cb = call.args return partitions_cb, zones_cb, outputs_cb + + +async def trigger_connection_status_update( + hass: HomeAssistant, + mock_satel: AsyncMock, + status: bool, +) -> None: + """Trigger a connection status update.""" + mock_satel.connected = status + for call in mock_satel.add_connection_status_callback.call_args_list: + call[0][0]() + await hass.async_block_till_done() diff --git a/tests/components/satel_integra/test_alarm_control_panel.py b/tests/components/satel_integra/test_alarm_control_panel.py index fd9886834ffece..934df661ae8edd 100644 --- a/tests/components/satel_integra/test_alarm_control_panel.py +++ b/tests/components/satel_integra/test_alarm_control_panel.py @@ -18,13 +18,20 @@ SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_DISARM, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import MOCK_CODE, MOCK_ENTRY_ID, get_monitor_callbacks, setup_integration +from . import ( + MOCK_CODE, + MOCK_ENTRY_ID, + get_monitor_callbacks, + setup_integration, + trigger_connection_status_update, +) from tests.common import ( MockConfigEntry, @@ -240,3 +247,24 @@ async def test_alarm_panel_last_reported( assert first_reported != hass.states.get("alarm_control_panel.home").last_reported assert len(events) == 1 # last_reported shall not fire state_changed + + +async def test_availability( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_config_entry_with_subentries: MockConfigEntry, +) -> None: + """Test availability.""" + entity_id = "alarm_control_panel.home" + + await setup_integration(hass, mock_config_entry_with_subentries) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + + await trigger_connection_status_update(hass, mock_satel, False) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + await trigger_connection_status_update(hass, mock_satel, True) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED diff --git a/tests/components/satel_integra/test_binary_sensor.py b/tests/components/satel_integra/test_binary_sensor.py index 2f0d4854c34528..c9489d7b2e07df 100644 --- a/tests/components/satel_integra/test_binary_sensor.py +++ b/tests/components/satel_integra/test_binary_sensor.py @@ -9,12 +9,18 @@ from homeassistant.components.satel_integra.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import get_monitor_callbacks, setup_integration +from . import get_monitor_callbacks, setup_integration, trigger_connection_status_update from tests.common import ( MockConfigEntry, @@ -150,3 +156,24 @@ async def test_binary_sensor_last_reported( assert first_reported != hass.states.get("binary_sensor.zone").last_reported assert len(events) == 2 # last_reported shall not fire state_changed + + +async def test_availability( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_config_entry_with_subentries: MockConfigEntry, +) -> None: + """Test availability.""" + entity_id = "binary_sensor.zone" + + await setup_integration(hass, mock_config_entry_with_subentries) + + assert hass.states.get(entity_id).state == STATE_OFF + + await trigger_connection_status_update(hass, mock_satel, False) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + await trigger_connection_status_update(hass, mock_satel, True) + + assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/satel_integra/test_switch.py b/tests/components/satel_integra/test_switch.py index 2717041ada04ae..d6931918726336 100644 --- a/tests/components/satel_integra/test_switch.py +++ b/tests/components/satel_integra/test_switch.py @@ -18,6 +18,7 @@ CONF_CODE, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, ) @@ -26,7 +27,13 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import MOCK_CODE, MOCK_ENTRY_ID, get_monitor_callbacks, setup_integration +from . import ( + MOCK_CODE, + MOCK_ENTRY_ID, + get_monitor_callbacks, + setup_integration, + trigger_connection_status_update, +) from tests.common import ( MockConfigEntry, @@ -210,3 +217,24 @@ async def test_switch_actions_require_code( {ATTR_ENTITY_ID: "switch.switchable_output"}, blocking=True, ) + + +async def test_availability( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_config_entry_with_subentries: MockConfigEntry, +) -> None: + """Test availability.""" + entity_id = "switch.switchable_output" + + await setup_integration(hass, mock_config_entry_with_subentries) + + assert hass.states.get(entity_id).state == STATE_OFF + + await trigger_connection_status_update(hass, mock_satel, False) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + await trigger_connection_status_update(hass, mock_satel, True) + + assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/shopping_list/conftest.py b/tests/components/shopping_list/conftest.py index f1efbfe6ab7c18..b6e1ffbe747e1a 100644 --- a/tests/components/shopping_list/conftest.py +++ b/tests/components/shopping_list/conftest.py @@ -6,7 +6,8 @@ import pytest -from homeassistant.components.shopping_list import PERSISTENCE, intent as sl_intent +from homeassistant.components.shopping_list import intent as sl_intent +from homeassistant.components.shopping_list.common import PERSISTENCE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index 644f28c64d2015..250f110a8ddb0d 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -8,6 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.shopping_list import NoMatchingShoppingListItem +from homeassistant.components.shopping_list.common import _get_shopping_data from homeassistant.components.shopping_list.const import ( ATTR_REVERSE, DOMAIN, @@ -53,8 +54,8 @@ async def test_add_item( response = await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": " beer "}} ) - assert len(hass.data[DOMAIN].items) == 1 - assert hass.data[DOMAIN].items[0]["name"] == "beer" # name was trimmed + assert len(_get_shopping_data(hass).items) == 1 + assert _get_shopping_data(hass).items[0]["name"] == "beer" # name was trimmed # Response text is now handled by default conversation agent assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -74,21 +75,21 @@ async def test_remove_item( ) assert_shopping_list_data(hass, snapshot) - assert len(hass.data[DOMAIN].items) == 2 + assert len(_get_shopping_data(hass).items) == 2 # Remove a single item - item_id = hass.data[DOMAIN].items[0]["id"] - await hass.data[DOMAIN].async_remove(item_id) + item_id = _get_shopping_data(hass).items[0]["id"] + await _get_shopping_data(hass).async_remove(item_id) assert_shopping_list_data(hass, snapshot) - assert len(hass.data[DOMAIN].items) == 1 + assert len(_get_shopping_data(hass).items) == 1 - item = hass.data[DOMAIN].items[0] + item = _get_shopping_data(hass).items[0] assert item["name"] == "cheese" # Trying to remove the same item twice should fail with pytest.raises(NoMatchingShoppingListItem): - await hass.data[DOMAIN].async_remove(item_id) + await _get_shopping_data(hass).async_remove(item_id) assert_shopping_list_data(hass, snapshot) @@ -106,25 +107,27 @@ async def test_update_list( assert_shopping_list_data(hass, snapshot) # Update a single attribute, other attributes shouldn't change - await hass.data[DOMAIN].async_update_list({"complete": True}) + await _get_shopping_data(hass).async_update_list({"complete": True}) - beer = hass.data[DOMAIN].items[0] + beer = _get_shopping_data(hass).items[0] assert beer["name"] == "beer" assert beer["complete"] is True - cheese = hass.data[DOMAIN].items[1] + cheese = _get_shopping_data(hass).items[1] assert cheese["name"] == "cheese" assert cheese["complete"] is True # Update multiple attributes - await hass.data[DOMAIN].async_update_list({"name": "dupe", "complete": False}) + await _get_shopping_data(hass).async_update_list( + {"name": "dupe", "complete": False} + ) assert_shopping_list_data(hass, snapshot) - beer = hass.data[DOMAIN].items[0] + beer = _get_shopping_data(hass).items[0] assert beer["name"] == "dupe" assert beer["complete"] is False - cheese = hass.data[DOMAIN].items[1] + cheese = _get_shopping_data(hass).items[1] assert cheese["name"] == "dupe" assert cheese["complete"] is False @@ -145,16 +148,16 @@ async def test_clear_completed_items( ) assert_shopping_list_data(hass, snapshot) - assert len(hass.data[DOMAIN].items) == 2 + assert len(_get_shopping_data(hass).items) == 2 # Update a single attribute, other attributes shouldn't change - await hass.data[DOMAIN].async_update_list({"complete": True}) + await _get_shopping_data(hass).async_update_list({"complete": True}) assert_shopping_list_data(hass, snapshot) - await hass.data[DOMAIN].async_clear_completed() + await _get_shopping_data(hass).async_clear_completed() assert_shopping_list_data(hass, snapshot) - assert len(hass.data[DOMAIN].items) == 0 + assert len(_get_shopping_data(hass).items) == 0 async def test_recent_items_intent( @@ -263,8 +266,8 @@ async def test_deprecated_api_update( ) assert_shopping_list_data(hass, snapshot) - beer_id = hass.data["shopping_list"].items[0]["id"] - wine_id = hass.data["shopping_list"].items[1]["id"] + beer_id = _get_shopping_data(hass).items[0]["id"] + wine_id = _get_shopping_data(hass).items[1]["id"] client = await hass_client() events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) @@ -288,7 +291,7 @@ async def test_deprecated_api_update( data = await resp.json() assert data == {"id": wine_id, "name": "wine", "complete": True} - beer, wine = hass.data["shopping_list"].items + beer, wine = _get_shopping_data(hass).items assert beer == {"id": beer_id, "name": "soda", "complete": False} assert wine == {"id": wine_id, "name": "wine", "complete": True} @@ -308,8 +311,8 @@ async def test_ws_update_item( ) assert_shopping_list_data(hass, snapshot) - beer_id = hass.data["shopping_list"].items[0]["id"] - wine_id = hass.data["shopping_list"].items[1]["id"] + beer_id = _get_shopping_data(hass).items[0]["id"] + wine_id = _get_shopping_data(hass).items[1]["id"] client = await hass_ws_client(hass) events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) await client.send_json( @@ -342,7 +345,7 @@ async def test_ws_update_item( assert len(events) == 2 assert_shopping_list_data(hass, snapshot) - beer, wine = hass.data["shopping_list"].items + beer, wine = _get_shopping_data(hass).items assert beer == {"id": beer_id, "name": "soda", "complete": False} assert wine == {"id": wine_id, "name": "wine", "complete": True} @@ -368,7 +371,7 @@ async def test_api_update_fails( assert resp.status == HTTPStatus.NOT_FOUND assert len(events) == 0 - beer_id = hass.data["shopping_list"].items[0]["id"] + beer_id = _get_shopping_data(hass).items[0]["id"] resp = await client.post(f"/api/shopping_list/item/{beer_id}", json={"name": 123}) assert_shopping_list_data(hass, snapshot) @@ -426,8 +429,8 @@ async def test_deprecated_api_clear_completed( ) assert_shopping_list_data(hass, snapshot) - beer_id = hass.data["shopping_list"].items[0]["id"] - wine_id = hass.data["shopping_list"].items[1]["id"] + beer_id = _get_shopping_data(hass).items[0]["id"] + wine_id = _get_shopping_data(hass).items[1]["id"] client = await hass_client() events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) @@ -445,7 +448,7 @@ async def test_deprecated_api_clear_completed( assert len(events) == 2 assert_shopping_list_data(hass, snapshot) - items = hass.data["shopping_list"].items + items = _get_shopping_data(hass).items assert len(items) == 1 assert items[0] == {"id": wine_id, "name": "wine", "complete": False} @@ -465,8 +468,8 @@ async def test_ws_clear_items( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) assert_shopping_list_data(hass, snapshot) - beer_id = hass.data["shopping_list"].items[0]["id"] - wine_id = hass.data["shopping_list"].items[1]["id"] + beer_id = _get_shopping_data(hass).items[0]["id"] + wine_id = _get_shopping_data(hass).items[1]["id"] client = await hass_ws_client(hass) events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) await client.send_json( @@ -485,7 +488,7 @@ async def test_ws_clear_items( await client.send_json({"id": 6, "type": "shopping_list/items/clear"}) msg = await client.receive_json() assert msg["success"] is True - items = hass.data["shopping_list"].items + items = _get_shopping_data(hass).items assert len(items) == 1 assert items[0] == {"id": wine_id, "name": "wine", "complete": False} assert len(events) == 2 @@ -511,7 +514,7 @@ async def test_deprecated_api_create( assert data["complete"] is False assert len(events) == 1 - items = hass.data["shopping_list"].items + items = _get_shopping_data(hass).items assert len(items) == 1 assert items[0]["name"] == "soda" assert items[0]["complete"] is False @@ -531,7 +534,7 @@ async def test_deprecated_api_create_fail( assert_shopping_list_data(hass, snapshot) assert resp.status == HTTPStatus.BAD_REQUEST - assert len(hass.data["shopping_list"].items) == 0 + assert len(_get_shopping_data(hass).items) == 0 assert len(events) == 0 @@ -553,7 +556,7 @@ async def test_ws_add_item( assert len(events) == 1 assert_shopping_list_data(hass, snapshot) - items = hass.data["shopping_list"].items + items = _get_shopping_data(hass).items assert len(items) == 1 assert items[0]["name"] == "soda" assert items[0]["complete"] is False @@ -572,7 +575,7 @@ async def test_ws_add_item_fail( msg = await client.receive_json() assert msg["success"] is False assert len(events) == 0 - assert len(hass.data["shopping_list"].items) == 0 + assert len(_get_shopping_data(hass).items) == 0 assert_shopping_list_data(hass, snapshot) @@ -595,7 +598,7 @@ async def test_ws_remove_item( assert len(events) == 2 assert_shopping_list_data(hass, snapshot) - items = hass.data["shopping_list"].items + items = _get_shopping_data(hass).items assert len(items) == 2 await client.send_json( @@ -606,7 +609,7 @@ async def test_ws_remove_item( assert msg["success"] is True assert_shopping_list_data(hass, snapshot) - items = hass.data["shopping_list"].items + items = _get_shopping_data(hass).items assert len(items) == 1 assert items[0]["name"] == "cheese" @@ -627,7 +630,7 @@ async def test_ws_remove_item_fail( msg = await client.receive_json() assert msg["success"] is False assert len(events) == 1 - assert len(hass.data["shopping_list"].items) == 1 + assert len(_get_shopping_data(hass).items) == 1 assert_shopping_list_data(hass, snapshot) @@ -649,9 +652,9 @@ async def test_ws_reorder_items( ) assert_shopping_list_data(hass, snapshot) - beer_id = hass.data["shopping_list"].items[0]["id"] - wine_id = hass.data["shopping_list"].items[1]["id"] - apple_id = hass.data["shopping_list"].items[2]["id"] + beer_id = _get_shopping_data(hass).items[0]["id"] + wine_id = _get_shopping_data(hass).items[1]["id"] + apple_id = _get_shopping_data(hass).items[2]["id"] client = await hass_ws_client(hass) events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) @@ -665,17 +668,17 @@ async def test_ws_reorder_items( msg = await client.receive_json() assert msg["success"] is True assert len(events) == 1 - assert hass.data["shopping_list"].items[0] == { + assert _get_shopping_data(hass).items[0] == { "id": wine_id, "name": "wine", "complete": False, } - assert hass.data["shopping_list"].items[1] == { + assert _get_shopping_data(hass).items[1] == { "id": apple_id, "name": "apple", "complete": False, } - assert hass.data["shopping_list"].items[2] == { + assert _get_shopping_data(hass).items[2] == { "id": beer_id, "name": "beer", "complete": False, @@ -705,17 +708,17 @@ async def test_ws_reorder_items( msg = await client.receive_json() assert msg["success"] is True assert len(events) == 3 - assert hass.data["shopping_list"].items[0] == { + assert _get_shopping_data(hass).items[0] == { "id": apple_id, "name": "apple", "complete": False, } - assert hass.data["shopping_list"].items[1] == { + assert _get_shopping_data(hass).items[1] == { "id": beer_id, "name": "beer", "complete": False, } - assert hass.data["shopping_list"].items[2] == { + assert _get_shopping_data(hass).items[2] == { "id": wine_id, "name": "wine", "complete": True, @@ -741,9 +744,9 @@ async def test_ws_reorder_items_failure( ) assert_shopping_list_data(hass, snapshot) - beer_id = hass.data["shopping_list"].items[0]["id"] - wine_id = hass.data["shopping_list"].items[1]["id"] - apple_id = hass.data["shopping_list"].items[2]["id"] + beer_id = _get_shopping_data(hass).items[0]["id"] + wine_id = _get_shopping_data(hass).items[1]["id"] + apple_id = _get_shopping_data(hass).items[2]["id"] client = await hass_ws_client(hass) events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) @@ -788,7 +791,7 @@ async def test_add_item_service( {ATTR_NAME: "beer"}, blocking=True, ) - assert len(hass.data[DOMAIN].items) == 1 + assert len(_get_shopping_data(hass).items) == 1 assert len(events) == 1 assert_shopping_list_data(hass, snapshot) @@ -810,7 +813,7 @@ async def test_remove_item_service( {ATTR_NAME: "cheese"}, blocking=True, ) - assert len(hass.data[DOMAIN].items) == 2 + assert len(_get_shopping_data(hass).items) == 2 assert len(events) == 2 assert_shopping_list_data(hass, snapshot) @@ -820,8 +823,8 @@ async def test_remove_item_service( {ATTR_NAME: "beer"}, blocking=True, ) - assert len(hass.data[DOMAIN].items) == 1 - assert hass.data[DOMAIN].items[0]["name"] == "cheese" + assert len(_get_shopping_data(hass).items) == 1 + assert _get_shopping_data(hass).items[0]["name"] == "cheese" assert len(events) == 3 assert_shopping_list_data(hass, snapshot) @@ -837,7 +840,7 @@ async def test_clear_completed_items_service( {ATTR_NAME: "beer"}, blocking=True, ) - assert len(hass.data[DOMAIN].items) == 1 + assert len(_get_shopping_data(hass).items) == 1 assert len(events) == 1 assert_shopping_list_data(hass, snapshot) @@ -848,7 +851,7 @@ async def test_clear_completed_items_service( {ATTR_NAME: "beer"}, blocking=True, ) - assert len(hass.data[DOMAIN].items) == 1 + assert len(_get_shopping_data(hass).items) == 1 assert len(events) == 1 assert_shopping_list_data(hass, snapshot) @@ -859,7 +862,7 @@ async def test_clear_completed_items_service( {}, blocking=True, ) - assert len(hass.data[DOMAIN].items) == 0 + assert len(_get_shopping_data(hass).items) == 0 assert len(events) == 1 assert_shopping_list_data(hass, snapshot) @@ -888,9 +891,9 @@ async def test_sort_list_service( ) assert_shopping_list_data(hass, snapshot) - assert hass.data[DOMAIN].items[0][ATTR_NAME] == "aaa" - assert hass.data[DOMAIN].items[1][ATTR_NAME] == "ddd" - assert hass.data[DOMAIN].items[2][ATTR_NAME] == "zzz" + assert _get_shopping_data(hass).items[0][ATTR_NAME] == "aaa" + assert _get_shopping_data(hass).items[1][ATTR_NAME] == "ddd" + assert _get_shopping_data(hass).items[2][ATTR_NAME] == "zzz" assert len(events) == 1 # sort descending @@ -902,7 +905,7 @@ async def test_sort_list_service( ) assert_shopping_list_data(hass, snapshot) - assert hass.data[DOMAIN].items[0][ATTR_NAME] == "zzz" - assert hass.data[DOMAIN].items[1][ATTR_NAME] == "ddd" - assert hass.data[DOMAIN].items[2][ATTR_NAME] == "aaa" + assert _get_shopping_data(hass).items[0][ATTR_NAME] == "zzz" + assert _get_shopping_data(hass).items[1][ATTR_NAME] == "ddd" + assert _get_shopping_data(hass).items[2][ATTR_NAME] == "aaa" assert len(events) == 2 diff --git a/tests/components/shopping_list/test_intent.py b/tests/components/shopping_list/test_intent.py index 8d8813c3ddfd0b..7cf6baf3fd4520 100644 --- a/tests/components/shopping_list/test_intent.py +++ b/tests/components/shopping_list/test_intent.py @@ -1,5 +1,6 @@ """Test Shopping List intents.""" +from homeassistant.components.shopping_list.common import _get_shopping_data from homeassistant.core import HomeAssistant from homeassistant.helpers import intent @@ -27,8 +28,8 @@ async def test_complete_item_intent(hass: HomeAssistant, sl_setup) -> None: completed_items = response.speech_slots.get("completed_items") assert len(completed_items) == 2 assert completed_items[0]["name"] == "beer" - assert hass.data["shopping_list"].items[1]["complete"] - assert hass.data["shopping_list"].items[2]["complete"] + assert _get_shopping_data(hass).items[1]["complete"] + assert _get_shopping_data(hass).items[2]["complete"] # Complete again response = await intent.async_handle( @@ -37,8 +38,8 @@ async def test_complete_item_intent(hass: HomeAssistant, sl_setup) -> None: assert response.response_type == intent.IntentResponseType.ACTION_DONE assert response.speech_slots.get("completed_items") == [] - assert hass.data["shopping_list"].items[1]["complete"] - assert hass.data["shopping_list"].items[2]["complete"] + assert _get_shopping_data(hass).items[1]["complete"] + assert _get_shopping_data(hass).items[2]["complete"] async def test_complete_item_intent_not_found(hass: HomeAssistant, sl_setup) -> None: diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 91b7c233135609..271d2f489fc8b2 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -1,5 +1,6 @@ """Test the Tessie sensor platform.""" +from copy import deepcopy from datetime import timedelta from freezegun.api import FrozenDateTimeFactory @@ -14,11 +15,17 @@ TESSIE_SYNC_INTERVAL, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed -from .common import ERROR_AUTH, ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform +from .common import ( + ENERGY_HISTORY, + ERROR_AUTH, + ERROR_CONNECTION, + ERROR_UNKNOWN, + setup_platform, +) from tests.common import async_fire_time_changed @@ -184,21 +191,52 @@ async def test_coordinator_energy_history_error( async def test_coordinator_energy_history_invalid_data( hass: HomeAssistant, mock_energy_history, freezer: FrozenDateTimeFactory ) -> None: - """Tests that the energy history coordinator handles invalid data.""" + """Tests that the energy history coordinator handles invalid data gracefully.""" entry = await setup_platform(hass, [Platform.SENSOR]) coordinator = entry.runtime_data.energysites[0].history_coordinator assert coordinator is not None + # Capture state after successful initial load + state_before = hass.states.get("sensor.energy_site_grid_imported").state + mock_energy_history.reset_mock() mock_energy_history.side_effect = lambda *a, **kw: {"response": {}} freezer.tick(TESSIE_ENERGY_HISTORY_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() mock_energy_history.assert_called_once() - assert ( - hass.states.get("sensor.energy_site_grid_imported").state == STATE_UNAVAILABLE - ) - assert isinstance(coordinator.last_exception, UpdateFailed) - assert coordinator.last_exception.translation_domain == DOMAIN - assert coordinator.last_exception.translation_key == "invalid_energy_history_data" + + # Sensor should retain last good state rather than becoming unavailable + assert hass.states.get("sensor.energy_site_grid_imported").state == state_before + assert coordinator.last_exception is None + + +async def test_coordinator_energy_history_cold_start_invalid_data( + hass: HomeAssistant, mock_energy_history, freezer: FrozenDateTimeFactory +) -> None: + """Tests cold-start fallback when the very first energy history fetch has invalid data.""" + + mock_energy_history.side_effect = lambda *a, **kw: {"response": {}} + entry = await setup_platform(hass, [Platform.SENSOR]) + coordinator = entry.runtime_data.energysites[0].history_coordinator + assert coordinator is not None + + # Coordinator should not have raised an exception; data stays empty + assert coordinator.last_exception is None + assert coordinator.data == {} + + # Sensor should be unknown until the first successful fetch + assert hass.states.get("sensor.energy_site_grid_imported").state == STATE_UNKNOWN + + # Now recover: restore valid energy history data and trigger an update + mock_energy_history.side_effect = lambda *a, **kw: deepcopy(ENERGY_HISTORY) + mock_energy_history.reset_mock() + freezer.tick(TESSIE_ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_energy_history.assert_called_once() + + # Coordinator should have real data and no exception + assert coordinator.last_exception is None + assert coordinator.data["solar_energy_exported"] == 724 diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index e6c815692fe2ca..8ede7491dc662d 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -13,10 +13,10 @@ DeviceFunction, DeviceStatusRange, Manager, + SharingDeviceListener, ) from homeassistant.components.tuya.const import DOMAIN -from homeassistant.components.tuya.coordinator import DeviceListener from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.json import json_dumps @@ -31,8 +31,13 @@ ) -class MockDeviceListener(DeviceListener): - """Mocked DeviceListener for testing.""" +class TuyaNotificationHelper: + """Helper to raise manager events to device listeners.""" + + def __init__(self, hass: HomeAssistant, manager: Manager) -> None: + """Initialize the helper.""" + self.hass = hass + self.manager = manager async def _async_update_device( self, @@ -41,23 +46,22 @@ async def _async_update_device( dp_timestamps: dict[str, int] | None, ) -> None: """Trigger dispatcher_send for device update and wait for entity tasks to complete.""" - self.update_device(device, updated_status_properties, dp_timestamps) + for listener in self.manager.device_listeners: + listener.update_device(device, updated_status_properties, dp_timestamps) await self.hass.async_block_till_done() - async def async_send_add_device( - self, manager: Manager, device: CustomerDevice - ) -> None: + async def async_send_add_device(self, device: CustomerDevice) -> None: """Mock add device from the manager.""" - manager.device_map[device.id] = device - self.add_device(device) + self.manager.device_map[device.id] = device + for listener in self.manager.device_listeners: + listener.add_device(device) await self.hass.async_block_till_done() - async def async_send_remove_device( - self, manager: Manager, device: CustomerDevice - ) -> None: + async def async_send_remove_device(self, device: CustomerDevice) -> None: """Mock remove device from the manager.""" - manager.device_map.pop(device.id, None) - self.remove_device(device.id) + self.manager.device_map.pop(device.id, None) + for listener in self.manager.device_listeners: + listener.remove_device(device.id) await self.hass.async_block_till_done() async def async_mock_online(self, device: CustomerDevice) -> None: @@ -159,19 +163,13 @@ async def create_device(hass: HomeAssistant, mock_device_code: str) -> CustomerD return device -def create_listener(hass: HomeAssistant, manager: Manager) -> MockDeviceListener: - """Create a DeviceListener for testing.""" - listener = MockDeviceListener(hass, manager) - manager.add_device_listener(listener) - return listener - - def create_manager( terminal_id: str = "7cd96aff-6ec8-4006-b093-3dbff7947591", ) -> Manager: """Create a Manager for testing.""" manager = MagicMock(spec=Manager) manager.device_map = {} + manager.device_listeners = set() manager.mq = MagicMock() manager.mq.client = MagicMock() manager.mq.client.is_connected = MagicMock(return_value=True) @@ -179,6 +177,18 @@ def create_manager( # Meaningless URL / UUIDs manager.customer_api.endpoint = "https://apigw.tuyaeu.com" manager.terminal_id = terminal_id + + def _add_device_listener(listener: SharingDeviceListener): + """Add device listener.""" + manager.device_listeners.add(listener) + + def _remove_device_listener(listener: SharingDeviceListener): + """Remove device listener.""" + manager.device_listeners.discard(listener) + + manager.add_device_listener.side_effect = _add_device_listener + manager.remove_device_listener.side_effect = _remove_device_listener + return manager @@ -207,7 +217,7 @@ async def initialize_entry( async def check_selective_state_update( hass: HomeAssistant, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, freezer: FrozenDateTimeFactory, *, entity_id: str, @@ -231,13 +241,13 @@ async def check_selective_state_update( # Trigger device offline freezer.tick(10) - await mock_listener.async_mock_offline(mock_device) + await notification_helper.async_mock_offline(mock_device) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE assert hass.states.get(entity_id).last_reported.isoformat() == unavailable_reported # Trigger device online freezer.tick(10) - await mock_listener.async_mock_online(mock_device) + await notification_helper.async_mock_online(mock_device) assert hass.states.get(entity_id).state == initial_state assert hass.states.get(entity_id).last_reported.isoformat() == available_reported @@ -245,12 +255,12 @@ async def check_selective_state_update( # in updated properties - state should not change freezer.tick(10) mock_device.status[dpcode] = None - await mock_listener.async_send_device_update(mock_device, {}) + await notification_helper.async_send_device_update(mock_device, {}) assert hass.states.get(entity_id).state == initial_state assert hass.states.get(entity_id).last_reported.isoformat() == available_reported # Trigger device update with provided updates freezer.tick(30) - await mock_listener.async_send_device_update(mock_device, updates) + await notification_helper.async_send_device_update(mock_device, updates) assert hass.states.get(entity_id).state == expected_state assert hass.states.get(entity_id).last_reported.isoformat() == last_reported diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 1c8e5b2b9235a3..7634dcb69ddad7 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -17,13 +17,7 @@ ) from homeassistant.core import HomeAssistant -from . import ( - DEVICE_MOCKS, - MockDeviceListener, - create_device, - create_listener, - create_manager, -) +from . import DEVICE_MOCKS, TuyaNotificationHelper, create_device, create_manager from tests.common import MockConfigEntry @@ -142,6 +136,8 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev @pytest.fixture -def mock_listener(hass: HomeAssistant, mock_manager: Manager) -> MockDeviceListener: - """Fixture for Tuya DeviceListener.""" - return create_listener(hass, mock_manager) +def notification_helper( + hass: HomeAssistant, mock_manager: Manager +) -> TuyaNotificationHelper: + """Fixture for Tuya NotificationHelper.""" + return TuyaNotificationHelper(hass, mock_manager) diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index 3071507f35ef4d..b5e43afe7ce273 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MockDeviceListener, check_selective_state_update, initialize_entry +from . import TuyaNotificationHelper, check_selective_state_update, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @@ -67,7 +67,7 @@ async def test_selective_state_update( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, freezer: FrozenDateTimeFactory, updates: dict[str, Any], expected_state: str, @@ -78,7 +78,7 @@ async def test_selective_state_update( await check_selective_state_update( hass, mock_device, - mock_listener, + notification_helper, freezer, entity_id="binary_sensor.window_downstairs_door", dpcode="doorcontact_state", @@ -108,7 +108,7 @@ async def test_bitmap( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, fault_value: int, tankfull: str, defrost: str, @@ -121,7 +121,9 @@ async def test_bitmap( assert hass.states.get("binary_sensor.dehumidifier_defrost").state == "off" assert hass.states.get("binary_sensor.dehumidifier_wet").state == "off" - await mock_listener.async_send_device_update(mock_device, {"fault": fault_value}) + await notification_helper.async_send_device_update( + mock_device, {"fault": fault_value} + ) assert hass.states.get("binary_sensor.dehumidifier_tank_full").state == tankfull assert hass.states.get("binary_sensor.dehumidifier_defrost").state == defrost diff --git a/tests/components/tuya/test_event.py b/tests/components/tuya/test_event.py index b269eb68ef29fc..68cbced10a416f 100644 --- a/tests/components/tuya/test_event.py +++ b/tests/components/tuya/test_event.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MockDeviceListener, initialize_entry +from . import TuyaNotificationHelper, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @@ -32,7 +32,7 @@ async def test_platform_setup_and_discovery( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -41,7 +41,9 @@ async def test_platform_setup_and_discovery( for mock_device in mock_devices: # Simulate an initial device update to generate events - await mock_listener.async_send_device_update(mock_device, mock_device.status) + await notification_helper.async_send_device_update( + mock_device, mock_device.status + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -70,7 +72,7 @@ async def test_alarm_message_event( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, snapshot: SnapshotAssertion, entity_id: str, dpcode: str, @@ -81,7 +83,7 @@ async def test_alarm_message_event( mock_device.status[dpcode] = value - await mock_listener.async_send_device_update(mock_device, mock_device.status) + await notification_helper.async_send_device_update(mock_device, mock_device.status) # Verify event was triggered with correct type and decoded URL state = hass.states.get(entity_id) @@ -99,7 +101,7 @@ async def test_selective_state_update( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, freezer: FrozenDateTimeFactory, ) -> None: """Ensure event is only triggered when device reports actual data.""" @@ -111,27 +113,33 @@ async def test_selective_state_update( # Device receives a data update - event gets triggered and state gets updated freezer.tick(10) - await mock_listener.async_send_device_update(mock_device, {"switch_mode1": "click"}) + await notification_helper.async_send_device_update( + mock_device, {"switch_mode1": "click"} + ) assert hass.states.get(entity_id).state == "2024-01-01T00:00:10.000+00:00" # Device goes offline freezer.tick(10) - await mock_listener.async_mock_offline(mock_device) + await notification_helper.async_mock_offline(mock_device) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # Device comes back online - state should go back to last known value, # not new datetime since no new data update has come in freezer.tick(10) - await mock_listener.async_mock_online(mock_device) + await notification_helper.async_mock_online(mock_device) assert hass.states.get(entity_id).state == "2024-01-01T00:00:10.000+00:00" # Device receives a new data update - event gets triggered and state gets updated freezer.tick(10) - await mock_listener.async_send_device_update(mock_device, {"switch_mode1": "click"}) + await notification_helper.async_send_device_update( + mock_device, {"switch_mode1": "click"} + ) assert hass.states.get(entity_id).state == "2024-01-01T00:00:40.000+00:00" # Device receives a data update on a different datapoint - event doesn't # get triggered and state doesn't get updated freezer.tick(10) - await mock_listener.async_send_device_update(mock_device, {"switch_mode2": "click"}) + await notification_helper.async_send_device_update( + mock_device, {"switch_mode2": "click"} + ) assert hass.states.get(entity_id).state == "2024-01-01T00:00:40.000+00:00" diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py index 3382c2be3d7fe7..daf8b596c7421d 100644 --- a/tests/components/tuya/test_init.py +++ b/tests/components/tuya/test_init.py @@ -20,7 +20,7 @@ from . import ( DEVICE_MOCKS, - MockDeviceListener, + TuyaNotificationHelper, create_device, create_manager, initialize_entry, @@ -146,7 +146,7 @@ async def test_dynamic_add_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_manager: Manager, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, device_registry: dr.DeviceRegistry, ) -> None: """Ensure add device event works correctly.""" @@ -160,7 +160,7 @@ async def test_dynamic_add_device( assert len(all_entries) == 1 # Trigger add second device from the manager - await mock_listener.async_send_add_device(mock_manager, second_device) + await notification_helper.async_send_add_device(second_device) # Should now have two devices in the registry all_entries = dr.async_entries_for_config_entry( @@ -177,7 +177,7 @@ async def test_dynamic_remove_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_manager: Manager, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, device_registry: dr.DeviceRegistry, ) -> None: """Ensure remove device event works correctly.""" @@ -193,7 +193,7 @@ async def test_dynamic_remove_device( assert len(all_entries) == 2 # Trigger remove second device from the manager - await mock_listener.async_send_remove_device(mock_manager, second_device) + await notification_helper.async_send_remove_device(second_device) # Only the main device should remain all_entries = dr.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index 3875b8bd6b50c2..3f4a44a1acb3e6 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MockDeviceListener, check_selective_state_update, initialize_entry +from . import TuyaNotificationHelper, check_selective_state_update, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @@ -71,7 +71,7 @@ async def test_selective_state_update( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, freezer: FrozenDateTimeFactory, updates: dict[str, Any], expected_state: str, @@ -82,7 +82,7 @@ async def test_selective_state_update( await check_selective_state_update( hass, mock_device, - mock_listener, + notification_helper, freezer, entity_id="number.multifunction_alarm_arm_delay", dpcode="delay_set", diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index 731aa649b20598..73e5c160e98ffd 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import MockDeviceListener, check_selective_state_update, initialize_entry +from . import TuyaNotificationHelper, check_selective_state_update, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @@ -72,7 +72,7 @@ async def test_selective_state_update( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, freezer: FrozenDateTimeFactory, updates: dict[str, Any], expected_state: str, @@ -83,7 +83,7 @@ async def test_selective_state_update( await check_selective_state_update( hass, mock_device, - mock_listener, + notification_helper, freezer, entity_id="select.kitchen_blinds_motor_mode", dpcode="control_back_mode", diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py index d2a59216161434..e9133b9f87b30d 100644 --- a/tests/components/tuya/test_sensor.py +++ b/tests/components/tuya/test_sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MockDeviceListener, check_selective_state_update, initialize_entry +from . import TuyaNotificationHelper, check_selective_state_update, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @@ -68,7 +68,7 @@ async def test_selective_state_update( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, freezer: FrozenDateTimeFactory, updates: dict[str, Any], expected_state: str, @@ -79,7 +79,7 @@ async def test_selective_state_update( await check_selective_state_update( hass, mock_device, - mock_listener, + notification_helper, freezer, entity_id="sensor.boite_aux_lettres_arriere_battery", dpcode="battery_percentage", @@ -96,7 +96,7 @@ async def test_delta_report_sensor( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, ) -> None: """Test delta report sensor behavior.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) @@ -110,7 +110,7 @@ async def test_delta_report_sensor( assert state.attributes["state_class"] == SensorStateClass.TOTAL_INCREASING # Send delta update - await mock_listener.async_send_device_update( + await notification_helper.async_send_device_update( mock_device, {"add_ele": 200}, {"add_ele": timestamp}, @@ -121,7 +121,7 @@ async def test_delta_report_sensor( # Send delta update (multiple dpcode) timestamp += 100 - await mock_listener.async_send_device_update( + await notification_helper.async_send_device_update( mock_device, {"add_ele": 300, "switch_1": True}, {"add_ele": timestamp, "switch_1": timestamp}, @@ -131,7 +131,7 @@ async def test_delta_report_sensor( assert float(state.state) == pytest.approx(0.5) # Send delta update (timestamp not incremented) - await mock_listener.async_send_device_update( + await notification_helper.async_send_device_update( mock_device, {"add_ele": 500}, {"add_ele": timestamp}, # same timestamp @@ -141,7 +141,7 @@ async def test_delta_report_sensor( assert float(state.state) == pytest.approx(0.5) # unchanged # Send delta update (unrelated dpcode) - await mock_listener.async_send_device_update( + await notification_helper.async_send_device_update( mock_device, {"switch_1": False}, {"switch_1": timestamp + 100}, @@ -152,7 +152,7 @@ async def test_delta_report_sensor( # Send delta update timestamp += 100 - await mock_listener.async_send_device_update( + await notification_helper.async_send_device_update( mock_device, {"add_ele": 100}, {"add_ele": timestamp}, @@ -164,7 +164,7 @@ async def test_delta_report_sensor( # Send delta update (None value) timestamp += 100 mock_device.status["add_ele"] = None - await mock_listener.async_send_device_update( + await notification_helper.async_send_device_update( mock_device, {"add_ele": None}, {"add_ele": timestamp}, @@ -175,7 +175,7 @@ async def test_delta_report_sensor( # Send delta update (no timestamp - skipped) mock_device.status["add_ele"] = 200 - await mock_listener.async_send_device_update( + await notification_helper.async_send_device_update( mock_device, {"add_ele": 200}, None, diff --git a/tests/components/tuya/test_siren.py b/tests/components/tuya/test_siren.py index 49771dd5e6af71..07add8d5b0595d 100644 --- a/tests/components/tuya/test_siren.py +++ b/tests/components/tuya/test_siren.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MockDeviceListener, check_selective_state_update, initialize_entry +from . import TuyaNotificationHelper, check_selective_state_update, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @@ -71,7 +71,7 @@ async def test_selective_state_update( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, freezer: FrozenDateTimeFactory, updates: dict[str, Any], expected_state: str, @@ -82,7 +82,7 @@ async def test_selective_state_update( await check_selective_state_update( hass, mock_device, - mock_listener, + notification_helper, freezer, entity_id="siren.c9", dpcode="siren_switch", diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py index b9705892e433af..e34fef5d252470 100644 --- a/tests/components/tuya/test_switch.py +++ b/tests/components/tuya/test_switch.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MockDeviceListener, check_selective_state_update, initialize_entry +from . import TuyaNotificationHelper, check_selective_state_update, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @@ -71,7 +71,7 @@ async def test_selective_state_update( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, freezer: FrozenDateTimeFactory, updates: dict[str, Any], expected_state: str, @@ -82,7 +82,7 @@ async def test_selective_state_update( await check_selective_state_update( hass, mock_device, - mock_listener, + notification_helper, freezer, entity_id="switch.din_socket", dpcode="switch", diff --git a/tests/components/tuya/test_valve.py b/tests/components/tuya/test_valve.py index b0fa504dc97225..cb80d702eeac80 100644 --- a/tests/components/tuya/test_valve.py +++ b/tests/components/tuya/test_valve.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MockDeviceListener, check_selective_state_update, initialize_entry +from . import TuyaNotificationHelper, check_selective_state_update, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @@ -71,7 +71,7 @@ async def test_selective_state_update( mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, - mock_listener: MockDeviceListener, + notification_helper: TuyaNotificationHelper, freezer: FrozenDateTimeFactory, updates: dict[str, Any], expected_state: str, @@ -82,7 +82,7 @@ async def test_selective_state_update( await check_selective_state_update( hass, mock_device, - mock_listener, + notification_helper, freezer, entity_id="valve.jie_hashui_fa_valve_1", dpcode="switch_1", diff --git a/tests/components/victron_gx/test_init.py b/tests/components/victron_gx/test_init.py index 4b54f79c1bc777..961a5b76d73c80 100644 --- a/tests/components/victron_gx/test_init.py +++ b/tests/components/victron_gx/test_init.py @@ -9,10 +9,14 @@ Hub as VictronVenusHub, MetricKind, ) +from victron_mqtt.testing import finalize_injection, inject_message +from homeassistant.components.victron_gx import async_remove_config_entry_device +from homeassistant.components.victron_gx.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .const import MOCK_INSTALLATION_ID @@ -210,3 +214,43 @@ async def test_hub_stop_disconnect_error( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_remove_config_entry_device( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing a device from the config entry.""" + victron_hub, mock_config_entry = init_integration + + # A device that was never discovered should be removable + device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, f"{MOCK_INSTALLATION_ID}_test_device")}, + ) + + result = await async_remove_config_entry_device( + hass, mock_config_entry, device_entry + ) + assert result is True + + # Inject a sensor to make battery_0 a known device + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/battery/0/Dc/0/Current", + '{"value": 10.5}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + # A device that is currently connected should NOT be removable + connected_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, f"{MOCK_INSTALLATION_ID}_battery_0")}, + ) + + result = await async_remove_config_entry_device( + hass, mock_config_entry, connected_device + ) + assert result is False