From 5b8bee141996daa016d2be78cc73d6a66b1547df Mon Sep 17 00:00:00 2001 From: Balloob Bot Date: Sun, 23 Aug 2026 18:16:53 +0200 Subject: [PATCH 01/24] Harden Z-Wave JS add-on config against concurrent and abandoned flows (#179816) Co-authored-by: Paulus Schoutsen Co-authored-by: Claude Fable 5 --- .../components/zwave_js/config_flow.py | 132 +++++++- tests/components/zwave_js/test_config_flow.py | 313 ++++++++++++++++++ 2 files changed, 428 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 16f5a647bb3321..1bc2b0034db18d 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -160,12 +160,19 @@ def get_schema(self, *, suggested: bool = False) -> dict[vol.Optional, type[str] ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61") -# Steps at which another flow is only showing a discovery prompt and can be -# aborted safely when a config entry is created by a different flow. -DISCOVERY_PROMPT_STEPS = { +# Steps at which another flow has not yet changed any shared state, +# e.g. the add-on config, and can be aborted safely when a config entry +# is created or a migration starts in a different flow. Steps that can +# be part of a migration, e.g. choose_serial_port, must not be in this +# set. +ABORT_SAFE_STEPS = { + "configure_addon_user", + "configure_security_keys", "confirm_usb_migration", "hassio_confirm", "installation_type", + "network_type", + "on_supervisor", "zeroconf_confirm", } @@ -269,6 +276,8 @@ def __init__(self, hass: HomeAssistant) -> None: # Set to True if the add-on was running when its config was changed, # meaning a restart instead of a start is needed. self.restart_addon = False + # Set to True once this flow has started a stopped add-on. + self.addon_started = False # The add-on config before this flow changed it, for reverts. self.original_config: dict[str, Any] | None = None @@ -299,7 +308,12 @@ async def async_set_addon_config(self, config_updates: dict) -> None: if addon_info.state is AddonState.RUNNING: self.restart_addon = True - self.original_config = dict(addon_config) + if self.original_config is None: + # Only capture the config before the first change, so a revert + # restores the config from before the flow, also if the flow + # changes the config multiple times, e.g. when the RF region + # step sets the region. + self.original_config = dict(addon_config) new_addon_config = SecurityKeys.migrate_network_key(new_addon_config) try: await self.addon_manager.async_set_addon_options(new_addon_config) @@ -325,6 +339,7 @@ async def async_start_addon_and_wait( if self.restart_addon: await self.addon_manager.async_schedule_restart_addon() else: + self.addon_started = True await self.addon_manager.async_schedule_start_addon() version_info: VersionInfo | None = None # Sleep some seconds to let the add-on start properly before connecting. @@ -452,7 +467,24 @@ async def async_step_start_addon( if rf_region is None or rf_region == "Automatic": # If the RF region is not set, we need to ask the user to select it. return await self.async_step_rf_region() + if ( + self._reconfigure_config_entry is None + and self._addon_owned_by_other_entry() + ): + # An add-on based entry was created while this flow was open, + # e.g. by a concurrent discovery flow. Abort before this flow + # overwrites the add-on config of that entry. + return self.async_abort(reason="addon_already_configured") + if config_updates := self._addon_config_updates: + if self._reconfigure_config_entry is None and any( + flow + for flow in self._async_in_progress() + if flow.get("step_id") not in ABORT_SAFE_STEPS + ): + # Another flow, e.g. a second discovered adapter being set + # up, is already changing the shared add-on config. + return self.async_abort(reason="already_in_progress") # If we have updates to the add-on config, # set them before starting the add-on. self._addon_config_updates = {} @@ -861,10 +893,7 @@ async def async_step_on_supervisor( self.use_addon = True - if any( - entry.data.get(CONF_USE_ADDON) and entry.unique_id != self.unique_id - for entry in self._async_current_entries(include_ignore=False) - ): + if self._addon_owned_by_other_entry(): # The add-on can only connect to a single adapter, so abort before # the flow changes the add-on config of the existing entry. # A discovery of the existing entry's own adapter passes, so the @@ -941,6 +970,26 @@ async def async_step_configure_addon_user( step_id="configure_addon_user", data_schema=data_schema, errors=errors ) + @callback + def _async_abort_other_prompt_flows(self) -> None: + """Abort other flows that are only showing a prompt. + + A created entry or a started migration may make them redundant. + Flows that have progressed further, e.g. a migration that has + backed up the network, must not be interrupted. + """ + for progress in self._async_in_progress(): + if progress.get("step_id") in ABORT_SAFE_STEPS: + self.hass.config_entries.flow.async_abort(progress["flow_id"]) + + @callback + def _addon_owned_by_other_entry(self) -> bool: + """Return if another config entry uses the add-on.""" + return any( + entry.data.get(CONF_USE_ADDON) and entry.unique_id != self.unique_id + for entry in self._async_current_entries(include_ignore=False) + ) + @callback def _validate_usb_or_socket_path(self) -> str | None: """Validate that exactly one of USB path and socket path is set.""" @@ -1073,13 +1122,7 @@ async def async_step_finish_addon_setup_user( @callback def _async_create_entry_from_vars(self) -> ConfigFlowResult: """Return a config entry for the flow.""" - # Abort other flows that are still at a discovery prompt, since the - # new entry may make them redundant. Flows that have progressed - # further, e.g. a migration that has backed up the network, - # must not be interrupted. - for progress in self._async_in_progress(): - if progress.get("step_id") in DISCOVERY_PROMPT_STEPS: - self.hass.config_entries.flow.async_abort(progress["flow_id"]) + self._async_abort_other_prompt_flows() return self.async_create_entry( title=TITLE, @@ -1135,8 +1178,45 @@ def async_remove(self) -> None: return config_entry = self._reconfigure_config_entry assert config_entry is not None - if config_entry.state is ConfigEntryState.NOT_LOADED: - self.hass.config_entries.async_schedule_reload(config_entry.entry_id) + if config_entry.state is not ConfigEntryState.NOT_LOADED: + return + if (original_config := self._addon_setup.original_config) is not None: + # The flow changed the add-on config without completing. + # Restore the config before reloading the entry, so the entry + # doesn't adopt the unconfirmed adapter and keys on setup. + self.hass.async_create_task( + self._async_restore_addon_config_and_reload(original_config) + ) + return + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) + + async def _async_restore_addon_config_and_reload( + self, original_config: dict[str, Any] + ) -> None: + """Restore the add-on config and reload the config entry.""" + config_entry = self._reconfigure_config_entry + assert config_entry is not None + addon_manager = self._addon_setup.addon_manager + # Migrate the legacy network key, like async_set_addon_config does, + # so restoring doesn't drop the S0 key on older add-on configurations. + restored_config = SecurityKeys.migrate_network_key(original_config) + try: + await addon_manager.async_set_addon_options(restored_config) + except AddonError as err: + # Don't reload the entry if the options were not restored, so the + # reload doesn't adopt the unconfirmed options still on the add-on. + _LOGGER.error("Failed to restore add-on options: %s", err) + return + if self._addon_setup.restart_addon or self._addon_setup.addon_started: + # The add-on is running with the unconfirmed options this flow + # set. Restart it before the reload, so the entry doesn't + # reconnect to the unconfirmed adapter. + try: + await addon_manager.async_restart_addon() + except AddonError as err: + _LOGGER.error("Failed to restart add-on: %s", err) + return + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) async def async_step_intent_reconfigure( self, user_input: dict[str, Any] | None = None @@ -1182,6 +1262,19 @@ async def async_step_intent_migrate( }, ) + if any( + flow + for flow in self._async_in_progress() + if flow.get("step_id") not in ABORT_SAFE_STEPS + ): + # Another flow, e.g. a competing migration confirmed earlier, + # has progressed beyond a prompt. Don't start a second migration. + return self.async_abort(reason="already_in_progress") + + # Remaining prompts, e.g. for other discovered adapters, + # are superseded by this migration. + self._async_abort_other_prompt_flows() + self._migrating = True return await self.async_step_backup_nvm() @@ -1534,6 +1627,11 @@ async def async_step_finish_addon_setup_migrate( CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, }, ) + # The migration is committed to the new adapter now, so drop the + # revert snapshot: if the flow is abandoned during the restore, the + # entry must be reloaded on the new adapter, not reverted to the old + # add-on config. + self._addon_setup.original_config = None return await self.async_step_restore_nvm() diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index d88410e1e16439..09fce595f1cb00 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1929,6 +1929,56 @@ async def test_esphome_discovery_without_home_id_can_be_ignored( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("supervisor", "addon_running", "backup_nvm") +async def test_esphome_discovery_competing_migration_prompts( + hass: HomeAssistant, + integration: MockConfigEntry, + client: MagicMock, +) -> None: + """Test starting a migration supersedes competing prompts.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="4321", data={**entry.data, "use_addon": True} + ) + + result_a = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + result_b = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result_a["step_id"] == "confirm_usb_migration" + assert result_b["step_id"] == "confirm_usb_migration" + + # Confirming the first migration removes the competing prompt. + with patch("pathlib.Path.write_bytes"): + result_a = await hass.config_entries.flow.async_configure( + result_a["flow_id"], {} + ) + await hass.async_block_till_done() + + assert result_a["type"] is FlowResultType.SHOW_PROGRESS + assert result_a["step_id"] == "backup_nvm" + assert not any( + flow["flow_id"] == result_b["flow_id"] + for flow in hass.config_entries.flow.async_progress() + ) + + # A migration confirmed while another is in flight is rejected. + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + @pytest.mark.usefixtures("supervisor", "addon_running", "addon_info") async def test_esphome_discovery_already_configured_unmanaged_addon( hass: HomeAssistant, @@ -2607,6 +2657,57 @@ async def test_usb_discovery_ignored( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon") +async def test_concurrent_usb_setup_flows( + hass: HomeAssistant, + set_addon_options: AsyncMock, + mock_usb_serial_by_id: MagicMock, +) -> None: + """Test a second setup flow can't change the add-on config concurrently.""" + second_stick = UsbServiceInfo( + device="/dev/zwave2", + pid="BBBB", + vid="BBBB", + serial_number="5678", + description="zwave radio", + manufacturer="test", + ) + result_a = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + result_b = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=second_stick, + ) + + assert result_a["step_id"] == "installation_type" + assert result_b["step_id"] == "installation_type" + + result_a = await hass.config_entries.flow.async_configure( + result_a["flow_id"], {"next_step_id": "intent_recommended"} + ) + + assert result_a["type"] is FlowResultType.SHOW_PROGRESS + assert result_a["step_id"] == "start_addon" + set_addon_options.reset_mock() + + # The second flow may not change the add-on config while the first + # flow is applying its own. + result_b = await hass.config_entries.flow.async_configure( + result_b["flow_id"], {"next_step_id": "intent_recommended"} + ) + + assert result_b["type"] is FlowResultType.ABORT + assert result_b["reason"] == "already_in_progress" + set_addon_options.assert_not_called() + + hass.config_entries.flow.async_abort(result_a["flow_id"]) + await hass.async_block_till_done() + + @pytest.mark.usefixtures("supervisor", "addon_info") async def test_abort_usb_discovery_addon_required(hass: HomeAssistant) -> None: """Test usb discovery aborted when existing entry not using add-on.""" @@ -2824,6 +2925,47 @@ async def test_reconfigure_addon_already_configured( set_addon_options.assert_not_called() +@pytest.mark.usefixtures("supervisor", "addon_installed") +async def test_addon_already_configured_at_addon_start( + hass: HomeAssistant, + set_addon_options: AsyncMock, +) -> None: + """Test the add-on start aborts when an add-on entry appeared late.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_recommended"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_addon_user" + + # An add-on based entry is configured while the flow shows the form, + # e.g. by a concurrent discovery flow. + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "url": "ws://localhost:3000", + "usb_path": "/other", + "use_addon": True, + }, + title=TITLE, + unique_id="4321", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_already_configured" + # The other entry's add-on config is untouched. + set_addon_options.assert_not_called() + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + @pytest.mark.usefixtures("supervisor", "addon_running") async def test_addon_running( hass: HomeAssistant, @@ -6003,6 +6145,177 @@ async def mock_backup_nvm_raw(): assert not hass.config_entries.flow.async_progress() +@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon") +async def test_reconfigure_abandoned_restores_addon_config( + hass: HomeAssistant, + integration: MockConfigEntry, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, +) -> None: + """Test an abandoned flow restores the add-on config it changed.""" + addon_options.update( + {"device": "/test", "network_key": "legacy", "s0_legacy_key": "old123"} + ) + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_addon_reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "usb_path": "/new", + "s0_legacy_key": "old123", + }, + ) + + assert set_addon_options.call_count == 1 + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + # The user closes the dialog instead of waiting for the restart. + with patch( + "homeassistant.components.zwave_js.async_setup_entry", return_value=True + ): + hass.config_entries.flow.async_abort(result["flow_id"]) + await hass.async_block_till_done() + + # The add-on config the flow changed is restored, without the legacy + # network key, before the reload recovers the entry. + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions(config={"device": "/test", "s0_legacy_key": "old123"}), + ) + assert entry.state is config_entries.ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon") +async def test_reconfigure_abandoned_restore_failure_keeps_unloaded( + hass: HomeAssistant, + integration: MockConfigEntry, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, +) -> None: + """Test the entry stays unloaded if restoring the add-on config fails.""" + addon_options.update({"device": "/test", "s0_legacy_key": "old123"}) + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_addon_reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "usb_path": "/new", + "s0_legacy_key": "old123", + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + # Restoring the original add-on options fails on the cleanup path. + set_addon_options.side_effect = SupervisorError("Boom") + + hass.config_entries.flow.async_abort(result["flow_id"]) + await hass.async_block_till_done() + + # The entry is left unloaded instead of adopting the unconfirmed options. + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon", "backup_nvm") +async def test_migrate_flow_abandoned_after_commit_keeps_new_config( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, + set_addon_options: AsyncMock, + get_server_version: AsyncMock, +) -> None: + """Test abandoning after the migration commit doesn't revert the add-on.""" + entry = integration + hass.config_entries.async_update_entry( + entry, + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/old", + }, + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + with patch("pathlib.Path.write_bytes"): + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + + _set_home_id(get_server_version, 5678) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USB_PATH: "/new"} + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + + set_addon_options.reset_mock() + + # The migration commits the entry to the new adapter and starts the + # restore, then the user closes the dialog. + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert entry.data[CONF_USB_PATH] == "/new" + + hass.config_entries.flow.async_abort(result["flow_id"]) + await hass.async_block_till_done() + + # The entry stays on the new adapter and the add-on config is not + # reverted to the old one. + assert entry.data[CONF_USB_PATH] == "/new" + for mock_call in set_addon_options.call_args_list: + assert mock_call.args[1].config.get(CONF_ADDON_DEVICE) != "/old" + + @pytest.mark.usefixtures("supervisor", "addon_installed") async def test_configure_addon_usb_ports_failure( hass: HomeAssistant, From 04fa8c9d8e995f600a1450719b43116f026fa41c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 23 Aug 2026 18:26:47 +0200 Subject: [PATCH 02/24] Bump nettigo_air_monitor to 5.1.0 (#179911) --- homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 95a39655c2a2f8..d5563a5e5da8b5 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], - "requirements": ["nettigo-air-monitor==5.0.0"], + "requirements": ["nettigo-air-monitor==5.1.0"], "zeroconf": [ { "name": "nam-*", diff --git a/requirements_all.txt b/requirements_all.txt index 632ab3aaaf2762..9b2dea074aa112 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1670,7 +1670,7 @@ netdata==1.3.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==5.0.0 +nettigo-air-monitor==5.1.0 # homeassistant.components.neurio_energy neurio==0.3.1 From f40533f21b009b78e60408a793281162e615709b Mon Sep 17 00:00:00 2001 From: Alexander Birkner Date: Sun, 23 Aug 2026 18:29:21 +0200 Subject: [PATCH 03/24] Migrate devcontainer.json to use build.dockerfile/build.context (#179909) --- .devcontainer/devcontainer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cb1b5b959a2020..ce0e5fdeaf091f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,9 @@ { "name": "Home Assistant Dev", - "context": "..", - "dockerFile": "../Dockerfile.dev", + "build": { + "dockerfile": "../Dockerfile.dev", + "context": ".." + }, "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && script/setup", "postStartCommand": "script/bootstrap", "containerEnv": { From ee8b7874bc0524ad006ee8a19f71c4c09b120be2 Mon Sep 17 00:00:00 2001 From: Lukas <12813107+lmaertin@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:30:55 +0200 Subject: [PATCH 04/24] Bump python-pooldose to 0.9.10 (#179903) Co-authored-by: Paulus Schoutsen --- homeassistant/components/pooldose/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pooldose/manifest.json b/homeassistant/components/pooldose/manifest.json index 2e69dae58b29be..d82ed00ec56260 100644 --- a/homeassistant/components/pooldose/manifest.json +++ b/homeassistant/components/pooldose/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["python-pooldose==0.9.6"] + "requirements": ["python-pooldose==0.9.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9b2dea074aa112..0faf67ebd00031 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2750,7 +2750,7 @@ python-overseerr==0.9.0 python-picnic-api2==2.0.1 # homeassistant.components.pooldose -python-pooldose==0.9.6 +python-pooldose==0.9.10 # homeassistant.components.hr_energy_qube python-qube-heatpump==1.12.0 From 7e1ffafc59075550dafd4ede4a199a871c83d9fd Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 23 Aug 2026 18:33:01 +0200 Subject: [PATCH 05/24] Fix LG ThinQ failing to set up during a temporary network outage (#179901) --- homeassistant/components/lg_thinq/__init__.py | 11 ++++++ tests/components/lg_thinq/test_init.py | 35 +++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index ad0bfdbfc7aa9b..13e9793aa771a3 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field import logging +from aiohttp import ClientError from thinqconnect import ThinQApi, ThinQAPIException from thinqconnect.integration import async_get_ha_bridge_list @@ -93,6 +94,11 @@ async def async_setup_coordinators( bridge_list = await async_get_ha_bridge_list(thinq_api) except ThinQAPIException as exc: raise ConfigEntryNotReady(exc.message) from exc + except (ClientError, TimeoutError) as exc: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from exc if not bridge_list: _LOGGER.warning("No devices registered with the correct profile") @@ -144,6 +150,11 @@ async def async_setup_mqtt( translation_key="failed_to_connect_mqtt", translation_placeholders={"error": str(exc)}, ) from exc + except (ClientError, TimeoutError) as exc: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from exc if not result: _LOGGER.error("Failed to set up mqtt connection") diff --git a/tests/components/lg_thinq/test_init.py b/tests/components/lg_thinq/test_init.py index d4c14e2e0c08f6..9b49bb44be9050 100644 --- a/tests/components/lg_thinq/test_init.py +++ b/tests/components/lg_thinq/test_init.py @@ -2,7 +2,9 @@ from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest +from thinqconnect import ThinQAPIException from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -32,14 +34,17 @@ async def test_load_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.parametrize("exception", [AttributeError(), TypeError(), ValueError()]) -async def test_config_not_ready( +@pytest.mark.parametrize( + "exception", + [AttributeError(), TypeError(), ValueError(), ClientError(), TimeoutError()], +) +async def test_config_not_ready_mqtt( hass: HomeAssistant, mock_thinq_api: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, ) -> None: - """Test for setup failure exception occurred.""" + """Test for setup failure exception occurred during MQTT setup.""" with patch( "homeassistant.components.lg_thinq.ThinQMQTT.async_connect", side_effect=exception, @@ -47,3 +52,27 @@ async def test_config_not_ready( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "exception", + [ + ThinQAPIException(code="1309", message="Not allowed api call", headers={}), + ClientError(), + TimeoutError(), + ], +) +async def test_config_not_ready_bridge_list( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test for setup failure exception occurred during coordinator setup.""" + with patch( + "homeassistant.components.lg_thinq.async_get_ha_bridge_list", + side_effect=exception, + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 41a9ea753bb0e61aa17fce811edf0d7c905f3d90 Mon Sep 17 00:00:00 2001 From: Balloob Bot Date: Sun, 23 Aug 2026 18:41:50 +0200 Subject: [PATCH 06/24] Add config entry migration for Z-Wave JS (#179898) Co-authored-by: Paulus Schoutsen Co-authored-by: Claude Fable 5 --- homeassistant/components/zwave_js/__init__.py | 29 +++++--- .../components/zwave_js/config_flow.py | 1 + tests/components/zwave_js/test_init.py | 68 +++++++++++++++++++ 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index fc62275f4286be..7e9c4985471f76 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -162,17 +162,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Z-Wave JS component.""" - for entry in hass.config_entries.async_entries(DOMAIN): - if not isinstance(entry.unique_id, str): - hass.config_entries.async_update_entry( - entry, unique_id=str(entry.unique_id) - ) - async_setup_services(hass) return True +async def async_migrate_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> bool: + """Migrate old config entry.""" + if entry.version == 1 and entry.minor_version < 2: + unique_id = entry.unique_id + if not isinstance(unique_id, str): + # Old entries stored the home ID as int. + unique_id = str(unique_id) + data = dict(entry.data) + # s0_legacy_key was saved as network_key before s2 was added. + if CONF_NETWORK_KEY in data: + network_key = data.pop(CONF_NETWORK_KEY) + if not data.get(CONF_S0_LEGACY_KEY): + data[CONF_S0_LEGACY_KEY] = network_key + hass.config_entries.async_update_entry( + entry, data=data, unique_id=unique_id, minor_version=2 + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> bool: """Set up Z-Wave JS from a config entry.""" if use_addon := entry.data.get(CONF_USE_ADDON): @@ -1210,10 +1224,7 @@ async def async_ensure_addon_running( usb_path: str | None = entry.data[CONF_USB_PATH] socket_path: str | None = entry.data.get(CONF_SOCKET_PATH) - # s0_legacy_key was saved as network_key before s2 was added. s0_legacy_key: str = entry.data.get(CONF_S0_LEGACY_KEY, "") - if not s0_legacy_key: - s0_legacy_key = entry.data.get(CONF_NETWORK_KEY, "") s2_access_control_key: str = entry.data.get(CONF_S2_ACCESS_CONTROL_KEY, "") s2_authenticated_key: str = entry.data.get(CONF_S2_AUTHENTICATED_KEY, "") s2_unauthenticated_key: str = entry.data.get(CONF_S2_UNAUTHENTICATED_KEY, "") diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 1bc2b0034db18d..074ad05fed37b6 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -390,6 +390,7 @@ def _addon_setup(self) -> AddonFlowManager: return AddonFlowManager(self.hass) VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Set up flow instance.""" diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 90703a9f4d0196..519def8e2220b6 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -62,6 +62,74 @@ def connect_timeout_fixture() -> Generator[int]: yield timeout +@pytest.mark.parametrize( + ("unique_id", "data", "expected_unique_id", "expected_data"), + [ + pytest.param( + 3245146787, + {"url": "ws://test.org"}, + "3245146787", + {"url": "ws://test.org"}, + id="int_unique_id", + ), + pytest.param( + "3245146787", + {"url": "ws://test.org", "network_key": "abc123"}, + "3245146787", + {"url": "ws://test.org", "s0_legacy_key": "abc123"}, + id="network_key_only", + ), + pytest.param( + "3245146787", + { + "url": "ws://test.org", + "network_key": "abc123", + "s0_legacy_key": "def456", + }, + "3245146787", + {"url": "ws://test.org", "s0_legacy_key": "def456"}, + id="existing_s0_legacy_key_wins", + ), + ], +) +@pytest.mark.usefixtures("client") +async def test_migrate_entry( + hass: HomeAssistant, + unique_id: int | str, + data: dict[str, Any], + expected_unique_id: str, + expected_data: dict[str, Any], +) -> None: + """Test migration of a version 1.1 config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, data=data, unique_id=unique_id, minor_version=1 + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.zwave_js.PLATFORMS", []): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == expected_unique_id + assert dict(entry.data) == expected_data + + +async def test_migrate_entry_from_future_version(hass: HomeAssistant) -> None: + """Test migration of a config entry from a future version fails.""" + entry = MockConfigEntry( + domain=DOMAIN, data={"url": "ws://test.org"}, unique_id="3245146787", version=2 + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + async def test_entry_setup_unload( hass: HomeAssistant, client: MagicMock, From 2c6f11befc378ea6916370343c08e2cadc1950b7 Mon Sep 17 00:00:00 2001 From: Balloob Bot Date: Sun, 23 Aug 2026 19:26:09 +0200 Subject: [PATCH 07/24] Apply Z-Wave JS add-on config reverts directly (#179899) Co-authored-by: Paulus Schoutsen Co-authored-by: Claude Fable 5 --- .../components/zwave_js/config_flow.py | 46 +++++-------------- tests/components/zwave_js/test_config_flow.py | 38 ++++++--------- 2 files changed, 25 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 074ad05fed37b6..780025be71ed8c 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -149,11 +149,6 @@ def get_schema(self, *, suggested: bool = False) -> dict[vol.Optional, type[str] } -ADDON_USER_INPUT_MAP = { - CONF_ADDON_DEVICE: CONF_USB_PATH, - CONF_ADDON_SOCKET: CONF_SOCKET_PATH, -} | {field.name: field.name for field in fields(SecurityKeys)} - CONF_ADDON_RF_REGION = "rf_region" EXAMPLE_SERVER_URL = "ws://localhost:3000" @@ -403,7 +398,6 @@ def __init__(self) -> None: self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None self.version_info: VersionInfo | None = None - self.revert_reason: str | None = None self.backup_task: asyncio.Task | None = None self.restore_backup_task: asyncio.Task | None = None self.backup_data: bytes | None = None @@ -1462,8 +1456,7 @@ async def async_step_configure_addon_reconfigure( if user_input is not None: # Missing keys default to the current add-on config, so - # existing keys are preserved. The revert helper always passes - # all keys, so the defaults never apply while reverting. + # existing keys are preserved. self.security_keys = default_keys.updated_from_user_input(user_input) self.usb_path = user_input.get(CONF_USB_PATH) or None self.socket_path = user_input.get(CONF_SOCKET_PATH) or None @@ -1646,11 +1639,6 @@ async def async_step_finish_addon_setup_reconfigure( """ config_entry = self._reconfigure_config_entry assert config_entry is not None - if self.revert_reason: - self._addon_setup.original_config = None - reason = self.revert_reason - self.revert_reason = None - return await self.async_revert_addon_config(reason=reason) if not self.ws_address: discovery_info = await self._addon_setup.async_get_addon_discovery_info() @@ -1801,32 +1789,20 @@ async def async_step_esphome( return await self.async_step_installation_type() async def async_revert_addon_config(self, reason: str) -> ConfigFlowResult: - """Abort the options flow. + """Abort the flow. If the add-on options have been changed, revert those and restart add-on. """ - # If reverting the add-on options failed, abort immediately. - if self.revert_reason: - _LOGGER.error( - "Failed to revert add-on options before aborting flow, reason: %s", - reason, - ) - - if self.revert_reason or not self._addon_setup.original_config: - config_entry = self._reconfigure_config_entry - assert config_entry is not None + _LOGGER.debug("Reverting add-on options, reason: %s", reason) + if (original_config := self._addon_setup.original_config) is None: self._async_schedule_entry_reload() - return self.async_abort(reason=reason) - - self.revert_reason = reason - original_config = self._addon_setup.original_config - addon_config_input = SecurityKeys.from_config(original_config).to_dict() | { - ADDON_USER_INPUT_MAP[addon_key]: addon_val - for addon_key, addon_val in original_config.items() - if addon_key in (CONF_ADDON_DEVICE, CONF_ADDON_SOCKET) - } - _LOGGER.debug("Reverting app options, reason: %s", reason) - return await self.async_step_configure_addon_reconfigure(addon_config_input) + else: + # Clear the abandoned-flow recovery state, so async_remove + # doesn't restore the add-on config a second time. + self._addon_setup.original_config = None + self._entry_unloaded_by_flow = False + await self._async_restore_addon_config_and_reload(original_config) + return self.async_abort(reason=reason) async def _async_backup_network(self) -> None: """Backup the current network.""" diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 09fce595f1cb00..5a1e7de5fd47db 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -4595,8 +4595,6 @@ async def different_device_server_version(*args): "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", - "lr_s2_access_control_key": "", - "lr_s2_authenticated_key": "", }, 0, different_device_server_version, @@ -4673,17 +4671,8 @@ async def test_reconfigure_different_device( assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=revert_addon_options) ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_addon" - - await hass.async_block_till_done() - assert restart_addon.call_count == 2 assert restart_addon.call_args == call("core_zwave_js") - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "different_device" assert entry.data == data @@ -4700,9 +4689,11 @@ async def test_reconfigure_different_device( "new_addon_options", "disconnect_calls", "restart_addon_side_effect", + "final_connect_calls", + "final_disconnect_calls", ), [ - ( + pytest.param( {}, { "device": "/test", @@ -4734,8 +4725,11 @@ async def test_reconfigure_different_device( }, 0, [SupervisorError(), None], + 2, + 1, + id="revert_restart_success", ), - ( + pytest.param( {}, { "device": "/test", @@ -4770,6 +4764,9 @@ async def test_reconfigure_different_device( SupervisorError(), SupervisorError(), ], + 1, + 0, + id="revert_restart_failed", ), ], ) @@ -4785,6 +4782,8 @@ async def test_reconfigure_addon_restart_failed( form_data: dict[str, Any], new_addon_options: dict[str, Any], disconnect_calls: int, + final_connect_calls: int, + final_disconnect_calls: int, ) -> None: """Test reconfigure flow and add-on restart failure.""" addon_options.update(old_addon_options) @@ -4843,22 +4842,13 @@ async def test_reconfigure_addon_restart_failed( assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=old_addon_options) ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_addon" - - await hass.async_block_till_done() - assert restart_addon.call_count == 2 assert restart_addon.call_args == call("core_zwave_js") - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_start_failed" assert entry.data == data - assert client.connect.call_count == 2 - assert client.disconnect.call_count == 1 + assert client.connect.call_count == final_connect_calls + assert client.disconnect.call_count == final_disconnect_calls @pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon") From 2d4e451837b82a2041f206f3bd3c82d3f8d7eaab Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:26:28 +0200 Subject: [PATCH 08/24] Add custom IR-filter-only and manual infrared modes to UniFi Protect cameras (#174794) --- .../components/unifiprotect/select.py | 2 + .../components/unifiprotect/strings.json | 4 +- tests/components/unifiprotect/test_select.py | 47 +++++++++++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 888267d346a523..be4984e35519e0 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -68,6 +68,8 @@ {"id": IRLEDMode.ON.value, "name": "on"}, {"id": IRLEDMode.AUTO_NO_LED.value, "name": "auto_filter_only"}, {"id": IRLEDMode.CUSTOM.value, "name": "custom"}, + {"id": IRLEDMode.CUSTOM_FILTER_ONLY.value, "name": "custom_filter_only"}, + {"id": IRLEDMode.MANUAL.value, "name": "manual"}, {"id": IRLEDMode.OFF.value, "name": "off"}, ] diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 111e0f722ebf9d..cba086a1ca6a86 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -430,7 +430,9 @@ "state": { "auto": "Auto", "auto_filter_only": "Auto (filter only, no LEDs)", - "custom": "Auto (custom lux)", + "custom": "Custom (IR filter + LEDs)", + "custom_filter_only": "Custom (IR filter only)", + "manual": "Manual", "off": "Always disable", "on": "Always enable" } diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index bb221d448cee0b..830b494314d541 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -561,8 +561,49 @@ async def test_select_set_option_camera_recording( mock_method.assert_called_once_with(RecordingMode.NEVER) +@pytest.mark.parametrize( + ("mode", "expected"), + [ + (IRLEDMode.CUSTOM_FILTER_ONLY, "custom_filter_only"), + (IRLEDMode.MANUAL, "manual"), + (IRLEDMode.CUSTOM, "custom"), + ], +) +async def test_select_camera_ir_current_option( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + mode: IRLEDMode, + expected: str, +) -> None: + """A camera already in one of these modes reports it as the current option.""" + doorbell.isp_settings.ir_led_mode = mode + + await init_entry(hass, ufp, [doorbell]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[1] + ) + state = hass.states.get(entity_id) + assert state + assert state.state == expected + assert expected in state.attributes[ATTR_OPTIONS] + + +@pytest.mark.parametrize( + ("option", "expected"), + [ + ("on", IRLEDMode.ON), + ("custom_filter_only", IRLEDMode.CUSTOM_FILTER_ONLY), + ("manual", IRLEDMode.MANUAL), + ], +) async def test_select_set_option_camera_ir( - hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + option: str, + expected: IRLEDMode, ) -> None: """Test Infrared Mode select.""" @@ -579,11 +620,11 @@ async def test_select_set_option_camera_ir( await hass.services.async_call( "select", "select_option", - {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "on"}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option}, blocking=True, ) - mock_method.assert_called_once_with(IRLEDMode.ON) + mock_method.assert_called_once_with(expected) async def test_select_set_option_camera_doorbell_custom( From 3ad1d0d974ce3d76261c88b1213d922a301cc4d4 Mon Sep 17 00:00:00 2001 From: Balloob Bot Date: Sun, 23 Aug 2026 19:31:51 +0200 Subject: [PATCH 09/24] Remove redundant stale device removal from Z-Wave JS migration (#179918) Co-authored-by: Paulus Schoutsen Co-authored-by: Claude Fable 5 --- .../components/zwave_js/config_flow.py | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 780025be71ed8c..af055fc1f027cd 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -33,7 +33,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, selector +from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo @@ -56,13 +56,7 @@ CONF_USE_ADDON, DOMAIN, ) -from .helpers import ( - CannotConnect, - async_get_version_info, - format_home_id_for_display, - get_device_id, - get_device_id_ext, -) +from .helpers import CannotConnect, async_get_version_info, format_home_id_for_display from .models import ZwaveJSConfigEntry _LOGGER = logging.getLogger(__name__) @@ -1894,21 +1888,6 @@ def set_controller_reset(event: dict) -> None: with suppress(TimeoutError): async with asyncio.timeout(helpers.DRIVER_READY_EVENT_TIMEOUT): await controller_reset.wait() - - if own_node := controller.own_node: - device_registry = dr.async_get(self.hass) - if ( - (device_id_ext := get_device_id_ext(driver, own_node)) - and ( - old_device := device_registry.async_get_device_by_identifier( - get_device_id(driver, own_node), config_entry.entry_id - ) - ) - and device_id_ext not in old_device.identifiers - ): - # The old controller device is stale, and unlike the - # integration, the flow knows the controller was replaced. - device_registry.async_remove_device(old_device.id) finally: for unsub in unsubs: unsub() From f426931643fb342a320677e30d0ca02b26faa24b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 23 Aug 2026 19:37:52 +0200 Subject: [PATCH 10/24] Replace a caller supplied Authorization header case-insensitively (#179887) Co-authored-by: Claude --- .../helpers/config_entry_oauth2_flow.py | 16 +++------- .../helpers/test_config_entry_oauth2_flow.py | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 84e8971f86ccc5..1b75d4426d1b08 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -19,9 +19,10 @@ import time from typing import Any, cast, override -from aiohttp import ClientError, ClientResponseError, client, web +from aiohttp import ClientError, ClientResponseError, client, hdrs, web from habluetooth import BluetoothServiceInfoBleak import jwt +from multidict import CIMultiDict import voluptuous as vol from yarl import URL @@ -791,16 +792,9 @@ async def async_oauth2_request( This method will not refresh tokens. Use OAuth2 session for that. """ session = async_get_clientsession(hass) - headers = kwargs.pop("headers", {}) - return await session.request( - method, - url, - **kwargs, - headers={ - **headers, - "authorization": f"Bearer {token['access_token']}", - }, - ) + headers = CIMultiDict(kwargs.pop("headers", {})) + headers[hdrs.AUTHORIZATION] = f"Bearer {token['access_token']}" + return await session.request(method, url, **kwargs, headers=headers) @callback diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 75e582ccd27a39..658748bbf83cd0 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, patch from aiohttp import ClientError +from multidict import CIMultiDict import pytest from homeassistant import config_entries, data_entry_flow, setup @@ -1431,3 +1432,33 @@ async def empty_provider( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, config_entry ) + + +@pytest.mark.parametrize( + "header_name", + [ + pytest.param("Authorization", id="canonical_casing"), + pytest.param("authorization", id="lowercase"), + ], +) +async def test_oauth2_request_replaces_caller_authorization_header( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + header_name: str, +) -> None: + """Test the token replaces a caller supplied Authorization header.""" + aioclient_mock.post("https://example.com", status=201) + + await config_entry_oauth2_flow.async_oauth2_request( + hass, + {"access_token": ACCESS_TOKEN_1}, + "post", + "https://example.com", + headers={header_name: "Bearer caller supplied"}, + ) + + assert len(aioclient_mock.mock_calls) == 1 + headers = CIMultiDict(aioclient_mock.mock_calls[0][3]) + + # The token must not be sent as a second Authorization header + assert headers.getall("Authorization") == [f"Bearer {ACCESS_TOKEN_1}"] From ae331505c0e92f2bef89dbf38076869c378bef25 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 23 Aug 2026 19:39:49 +0200 Subject: [PATCH 11/24] Bump midea-local to 10.0.1 (#179925) --- homeassistant/components/midea/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/midea/manifest.json b/homeassistant/components/midea/manifest.json index 0e1ed59a46ae70..f85d2b702c0572 100644 --- a/homeassistant/components/midea/manifest.json +++ b/homeassistant/components/midea/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["midealocal"], "quality_scale": "bronze", - "requirements": ["midea-local==10.0.0"] + "requirements": ["midea-local==10.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0faf67ebd00031..c6b4c7ea513f76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1589,7 +1589,7 @@ micloud==0.5 microBeesPy==0.3.5 # homeassistant.components.midea -midea-local==10.0.0 +midea-local==10.0.1 # homeassistant.components.mill mill-local==0.5.0 From 5fb7c218a46064a9fdfa9fe723ae5fe84d449d79 Mon Sep 17 00:00:00 2001 From: Balloob Bot Date: Sun, 23 Aug 2026 19:42:19 +0200 Subject: [PATCH 12/24] Bump denon-rs232 to 4.2.2 (#179870) Co-authored-by: Paulus Schoutsen Co-authored-by: Claude Opus 5 --- homeassistant/components/denon_rs232/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/denon_rs232/manifest.json b/homeassistant/components/denon_rs232/manifest.json index d8a0c98e96d4c5..c4dbaf2c82a947 100644 --- a/homeassistant/components/denon_rs232/manifest.json +++ b/homeassistant/components/denon_rs232/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["denon_rs232"], "quality_scale": "bronze", - "requirements": ["denon-rs232==4.2.1"] + "requirements": ["denon-rs232==4.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c6b4c7ea513f76..c6735888e6054f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -829,7 +829,7 @@ deluge-client==1.10.2 demetriek==1.3.0 # homeassistant.components.denon_rs232 -denon-rs232==4.2.1 +denon-rs232==4.2.2 # homeassistant.components.denonavr denonavr==1.3.3 From 661fddbe0fa38d1aee11d4d81c58bb0e3ef0ba50 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:54:08 +0200 Subject: [PATCH 13/24] Bump guntamatic to v1.12.0 (#179921) --- homeassistant/components/guntamatic/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index f377576bd1ee6e..73e58be4030f6a 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.11.1"] + "requirements": ["guntamatic==1.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c6735888e6054f..35c5df0c2a1d20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1201,7 +1201,7 @@ growattServer==2.1.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.11.1 +guntamatic==1.12.0 # homeassistant.components.profiler guppy3==3.1.7 From d9f025d83f90c448653de17ec9173b52daa645c4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 23 Aug 2026 20:19:28 +0200 Subject: [PATCH 14/24] Forward service call context to entity in ai_task generate services (#179537) Co-authored-by: Claude --- homeassistant/components/ai_task/__init__.py | 6 ++++-- homeassistant/components/ai_task/entity.py | 14 +++++++++++--- homeassistant/components/ai_task/task.py | 6 +++++- tests/components/ai_task/conftest.py | 2 ++ tests/components/ai_task/test_init.py | 8 +++++++- tests/components/ai_task/test_task.py | 9 ++++++++- 6 files changed, 37 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index bc3b5db2cf27c6..c20b6cf1fb5435 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -146,13 +146,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_service_generate_data(call: ServiceCall) -> ServiceResponse: """Run the data task service.""" - result = await async_generate_data(hass=call.hass, **call.data) + result = await async_generate_data( + hass=call.hass, context=call.context, **call.data + ) return result.as_dict() async def async_service_generate_image(call: ServiceCall) -> ServiceResponse: """Run the image task service.""" - return await async_generate_image(hass=call.hass, **call.data) + return await async_generate_image(hass=call.hass, context=call.context, **call.data) class AITaskPreferences: diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index 5d4f6a5c12fed9..dc8f84817bb790 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -12,6 +12,7 @@ async_get_chat_log, ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import Context from homeassistant.helpers import llm from homeassistant.helpers.chat_session import ChatSession from homeassistant.helpers.restore_state import RestoreEntity @@ -61,6 +62,7 @@ async def _async_get_ai_task_chat_log( self, session: ChatSession, task: GenDataTask | GenImageTask, + context: Context | None, ) -> AsyncGenerator[ChatLog]: """Context manager used to manage the ChatLog used during an AI Task.""" user_llm_hass_api: llm.API | None = None @@ -78,7 +80,7 @@ async def _async_get_ai_task_chat_log( await chat_log.async_provide_llm_data( llm.LLMContext( platform=self.platform.domain, - context=None, + context=context, language=None, assistant=DOMAIN, device_id=None, @@ -98,11 +100,14 @@ async def internal_async_generate_data( self, session: ChatSession, task: GenDataTask, + context: Context | None = None, ) -> GenDataTaskResult: """Run a gen data task.""" + if context is not None: + self.async_set_context(context) self.__last_activity = dt_util.utcnow().isoformat() self.async_write_ha_state() - async with self._async_get_ai_task_chat_log(session, task) as chat_log: + async with self._async_get_ai_task_chat_log(session, task, context) as chat_log: return await self._async_generate_data(task, chat_log) async def _async_generate_data( @@ -118,11 +123,14 @@ async def internal_async_generate_image( self, session: ChatSession, task: GenImageTask, + context: Context | None = None, ) -> GenImageTaskResult: """Run a gen image task.""" + if context is not None: + self.async_set_context(context) self.__last_activity = dt_util.utcnow().isoformat() self.async_write_ha_state() - async with self._async_get_ai_task_chat_log(session, task) as chat_log: + async with self._async_get_ai_task_chat_log(session, task, context) as chat_log: return await self._async_generate_image(task, chat_log) async def _async_generate_image( diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 245318dab133e0..a29d186537baf7 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -12,7 +12,7 @@ from homeassistant.components import camera, conversation, image, media_source from homeassistant.components.http.auth import async_sign_path -from homeassistant.core import HomeAssistant, ServiceResponse, callback +from homeassistant.core import Context, HomeAssistant, ServiceResponse, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session @@ -119,6 +119,7 @@ async def async_generate_data( structure: vol.Schema | None = None, attachments: list[dict] | None = None, llm_api: llm.API | None = None, + context: Context | None = None, ) -> GenDataTaskResult: """Run a data generation task in the AI Task integration.""" if entity_id is None: @@ -156,6 +157,7 @@ async def async_generate_data( attachments=resolved_attachments or None, llm_api=llm_api, ), + context, ) @@ -166,6 +168,7 @@ async def async_generate_image( entity_id: str | None = None, instructions: str, attachments: list[dict] | None = None, + context: Context | None = None, ) -> ServiceResponse: """Run an image generation task in the AI Task integration.""" if entity_id is None: @@ -201,6 +204,7 @@ async def async_generate_image( instructions=instructions, attachments=resolved_attachments or None, ), + context, ) service_result = task_result.as_dict() diff --git a/tests/components/ai_task/conftest.py b/tests/components/ai_task/conftest.py index ceffb7c055e854..88d231afc6712b 100644 --- a/tests/components/ai_task/conftest.py +++ b/tests/components/ai_task/conftest.py @@ -48,12 +48,14 @@ def __init__(self) -> None: super().__init__() self.mock_generate_data_tasks = [] self.mock_generate_image_tasks = [] + self.mock_chat_logs = [] async def _async_generate_data( self, task: GenDataTask, chat_log: ChatLog ) -> GenDataTaskResult: """Mock handling of generate data task.""" self.mock_generate_data_tasks.append(task) + self.mock_chat_logs.append(chat_log) if task.structure is not None: data = {"name": "Tracy Chen", "age": 30} data_chat_log = json.dumps(data) diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index 4286cb62991e4a..f1d43a0ff1b37a 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -15,7 +15,7 @@ DATA_PREFERENCES, DOMAIN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector @@ -87,6 +87,7 @@ async def test_generate_data_service( mock_ai_task_entity: MockAITaskEntity, ) -> None: """Test the generate data service.""" + context = Context() preferences = hass.data[DATA_PREFERENCES] preferences.async_set_preferences(**set_preferences) @@ -108,9 +109,11 @@ async def test_generate_data_service( | msg_extra, blocking=True, return_response=True, + context=context, ) assert result["data"] == "Mock result" + assert hass.states.get(TEST_ENTITY_ID).context is context assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1 task = mock_ai_task_entity.mock_generate_data_tasks[0] @@ -317,6 +320,7 @@ async def test_generate_image_service( mock_ai_task_entity: MockAITaskEntity, ) -> None: """Test the generate image service.""" + context = Context() preferences = hass.data[DATA_PREFERENCES] preferences.async_set_preferences(**set_preferences) @@ -335,9 +339,11 @@ async def test_generate_image_service( | msg_extra, blocking=True, return_response=True, + context=context, ) mock_upload_media.assert_called_once() + assert hass.states.get(TEST_ENTITY_ID).context is context assert "image_data" not in result assert ( result["media_source_id"] diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index 2a5add49a9bcb1..f49e8fd7e04764 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -18,7 +18,7 @@ from homeassistant.components.conversation import async_get_chat_log from homeassistant.components.llm import AssistAPI from homeassistant.const import STATE_UNKNOWN -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session from homeassistant.util import dt as dt_util @@ -78,14 +78,21 @@ async def test_generate_data_preferred_entity( assert state is not None assert state.state == STATE_UNKNOWN + context = Context() llm_api = AssistAPI(hass) result = await async_generate_data( hass, task_name="Test Task", instructions="Test prompt", llm_api=llm_api, + context=context, ) assert result.data == "Mock result" + + # The LLM API uses the context to check permissions when calling tools + chat_log = mock_ai_task_entity.mock_chat_logs[0] + assert chat_log.llm_api is not None + assert chat_log.llm_api.llm_context.context is context as_dict = result.as_dict() assert as_dict["conversation_id"] == result.conversation_id assert as_dict["data"] == "Mock result" From 3683ac902facfbb4fa577a362f643d165d33af8f Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 23 Aug 2026 11:30:35 -0700 Subject: [PATCH 15/24] Remove portlandgeneral virtual integration (#179929) --- homeassistant/components/portlandgeneral/__init__.py | 1 - homeassistant/components/portlandgeneral/manifest.json | 6 ------ homeassistant/generated/integrations.json | 5 ----- 3 files changed, 12 deletions(-) delete mode 100644 homeassistant/components/portlandgeneral/__init__.py delete mode 100644 homeassistant/components/portlandgeneral/manifest.json diff --git a/homeassistant/components/portlandgeneral/__init__.py b/homeassistant/components/portlandgeneral/__init__.py deleted file mode 100644 index 67ab073a01d843..00000000000000 --- a/homeassistant/components/portlandgeneral/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Virtual integration: Portland General Electric (PGE).""" diff --git a/homeassistant/components/portlandgeneral/manifest.json b/homeassistant/components/portlandgeneral/manifest.json deleted file mode 100644 index 1f3b00b0992adf..00000000000000 --- a/homeassistant/components/portlandgeneral/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "domain": "portlandgeneral", - "name": "Portland General Electric (PGE)", - "integration_type": "virtual", - "supported_by": "opower" -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fff661a17fb320..4ca7d7aa5aceba 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5573,11 +5573,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "portlandgeneral": { - "name": "Portland General Electric (PGE)", - "integration_type": "virtual", - "supported_by": "opower" - }, "powerfox": { "name": "Powerfox", "integrations": { From 1379995268dceffcd8addb61d1af50b82704fd02 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Sun, 23 Aug 2026 14:46:13 -0400 Subject: [PATCH 16/24] Upgrade ayla-iot-unofficial to 1.5.2 (#179745) --- homeassistant/components/fujitsu_fglair/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index 24cfaad3049d07..6dceb9d2237d70 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.4.7"] + "requirements": ["ayla-iot-unofficial==1.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 35c5df0c2a1d20..ff9194daf32e87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -618,7 +618,7 @@ avea==1.8.0 axis==74 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.7 +ayla-iot-unofficial==1.5.2 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From 1ff08153a1fd9691ce41e8f69ff4f96bd5fbb760 Mon Sep 17 00:00:00 2001 From: Balloob Bot Date: Sun, 23 Aug 2026 20:51:21 +0200 Subject: [PATCH 17/24] Share one Modbus connection between the integrations on a device (#179658) Co-authored-by: Paulus Schoutsen Co-authored-by: Claude Opus 5 --- homeassistant/components/modbus/__init__.py | 10 +- homeassistant/components/modbus/connection.py | 83 ++++++++ homeassistant/components/modbus/manifest.json | 2 +- requirements_all.txt | 3 + tests/components/modbus/test_connection.py | 190 ++++++++++++++++++ 5 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/modbus/connection.py create mode 100644 tests/components/modbus/test_connection.py diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index c5307beabd92f4..e8266d13a8d5a8 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -9,9 +9,17 @@ from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType +from .connection import async_get_unit from .const import DOMAIN from .modbus import DATA_MODBUS_HUBS, ModbusHub, async_modbus_setup -from .schemas import CONFIG_SCHEMA # noqa: F401 +from .schemas import CONFIG_SCHEMA + +__all__ = [ + "CONFIG_SCHEMA", + "ModbusHub", + "async_get_unit", + "get_hub", +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/modbus/connection.py b/homeassistant/components/modbus/connection.py new file mode 100644 index 00000000000000..bbbf79ba16e562 --- /dev/null +++ b/homeassistant/components/modbus/connection.py @@ -0,0 +1,83 @@ +"""Hand out Modbus units over connections shared between integrations.""" + +from dataclasses import dataclass +import logging + +from modbus_connection import ( + ModbusSerialParams, + ModbusTcpParams, + ModbusTlsParams, + ModbusUdpParams, + ModbusUnit, +) +from modbus_connection.tmodbus import ModbusConnection + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type ModbusParams = ( + ModbusTcpParams | ModbusUdpParams | ModbusTlsParams | ModbusSerialParams +) +type ModbusEndpoint = tuple[str, str, int] | tuple[str, str] + +DATA_MODBUS_CONNECTIONS: HassKey[dict[ModbusEndpoint, _SharedConnection]] = HassKey( + f"{DOMAIN}_connections" +) + + +@dataclass +class _SharedConnection: + """A connection and how many units are held on it.""" + + params: ModbusParams + connection: ModbusConnection + consumers: int = 0 + + +@callback +def async_get_unit( + hass: HomeAssistant, + entry: ConfigEntry, + params: ModbusParams, + unit_id: int, +) -> ModbusUnit: + """Return a unit on the connection these credentials describe. + + Consumers of one device share a connection, so their requests serialize + behind its lock. It is closed when the last config entry holding a unit on + it unloads. + + Raises `HomeAssistantError` if the device is already in use over different + link settings, which cannot both be honoured on one connection. + """ + endpoint = params.endpoint + connections = hass.data.setdefault(DATA_MODBUS_CONNECTIONS, {}) + if (shared := connections.get(endpoint)) is None: + shared = connections[endpoint] = _SharedConnection( + params, ModbusConnection(params) + ) + elif shared.params != params: + raise HomeAssistantError( + f"Modbus device {endpoint} is already in use with different link " + f"settings: {shared.params} against {params}" + ) + + shared.consumers += 1 + + async def _release() -> None: + """Give up this hold, closing behind the last one.""" + shared.consumers -= 1 + if shared.consumers or connections.get(endpoint) is not shared: + return + del connections[endpoint] + _LOGGER.debug("Closing the Modbus connection to %s", endpoint) + await shared.connection.close() + + entry.async_on_unload(_release) + return shared.connection.for_unit(unit_id) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 30945c8a13dfe3..48a3552e95a30f 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], - "requirements": ["pymodbus==3.13.1"] + "requirements": ["pymodbus==3.13.1", "modbus-connection[tmodbus]==4.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ff9194daf32e87..674d3e6659c776 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1606,6 +1606,9 @@ mitsubishi-comfort==0.5.2 # homeassistant.components.moat moat-ble==0.1.1 +# homeassistant.components.modbus +modbus-connection[tmodbus]==4.8.1 + # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 diff --git a/tests/components/modbus/test_connection.py b/tests/components/modbus/test_connection.py new file mode 100644 index 00000000000000..c41561af225836 --- /dev/null +++ b/tests/components/modbus/test_connection.py @@ -0,0 +1,190 @@ +"""Test handing out Modbus units over shared connections.""" + +from collections.abc import Callable, Generator +from unittest.mock import AsyncMock, patch + +from modbus_connection import ModbusSerialParams, ModbusTcpParams +import pytest + +from homeassistant.components.modbus.connection import ( + DATA_MODBUS_CONNECTIONS, + async_get_unit, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) + +type ConsumerFactory = Callable[[], MockConfigEntry] + + +class MockFlow(ConfigFlow): + """A config flow for the integration standing in for a consumer.""" + + +@pytest.fixture(name="consumer") +def consumer_fixture(hass: HomeAssistant) -> Generator[ConsumerFactory]: + """Return a factory for config entries that can be set up and unloaded.""" + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "test.config_flow") + + def _consumer() -> MockConfigEntry: + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + return entry + + with mock_config_flow("test", MockFlow): + yield _consumer + + +async def test_equal_credentials_share_one_connection( + hass: HomeAssistant, consumer: ConsumerFactory +) -> None: + """Two consumers of one device queue behind one link, not two. + + A device answering one conversation cannot be asked a second question + halfway through it. + """ + one = consumer() + await hass.config_entries.async_setup(one.entry_id) + two = consumer() + await hass.config_entries.async_setup(two.entry_id) + + async_get_unit(hass, one, ModbusTcpParams(host="1.2.3.4", port=502), 1) + async_get_unit(hass, two, ModbusTcpParams(host="1.2.3.4", port=502), 2) + + [shared] = hass.data[DATA_MODBUS_CONNECTIONS].values() + assert shared.consumers == 2 + + +async def test_different_credentials_get_their_own_connection( + hass: HomeAssistant, consumer: ConsumerFactory +) -> None: + """A different host, port or transport is a different device.""" + entry = consumer() + await hass.config_entries.async_setup(entry.entry_id) + + async_get_unit(hass, entry, ModbusTcpParams(host="1.2.3.4", port=502), 1) + async_get_unit(hass, entry, ModbusTcpParams(host="1.2.3.4", port=503), 1) + async_get_unit(hass, entry, ModbusSerialParams(device="/dev/ttyUSB0"), 1) + + assert len(hass.data[DATA_MODBUS_CONNECTIONS]) == 3 + + +async def test_the_same_device_reached_by_a_different_name_still_shares( + hass: HomeAssistant, consumer: ConsumerFactory +) -> None: + """Hostnames are case-insensitive, so the case must not split the link.""" + entry = consumer() + await hass.config_entries.async_setup(entry.entry_id) + + async_get_unit(hass, entry, ModbusTcpParams(host="Device.local", port=502), 1) + async_get_unit(hass, entry, ModbusTcpParams(host="device.local", port=502), 2) + + assert len(hass.data[DATA_MODBUS_CONNECTIONS]) == 1 + + +async def test_one_device_cannot_be_used_with_two_link_settings( + hass: HomeAssistant, consumer: ConsumerFactory +) -> None: + """One connection can only be framed one way, so the clash has to be said. + + Silently keeping the first would leave the second consumer reading a device + over settings it did not ask for. + """ + entry = consumer() + await hass.config_entries.async_setup(entry.entry_id) + + async_get_unit(hass, entry, ModbusTcpParams(host="1.2.3.4", port=502), 1) + + with pytest.raises(HomeAssistantError, match="different link settings"): + async_get_unit( + hass, entry, ModbusTcpParams(host="1.2.3.4", port=502, framer="rtu"), 2 + ) + + +async def test_the_last_consumer_closes_the_connection( + hass: HomeAssistant, consumer: ConsumerFactory +) -> None: + """A connection lives exactly as long as somebody holds a unit on it.""" + one = consumer() + await hass.config_entries.async_setup(one.entry_id) + two = consumer() + await hass.config_entries.async_setup(two.entry_id) + + async_get_unit(hass, one, ModbusTcpParams(host="1.2.3.4", port=502), 1) + async_get_unit(hass, two, ModbusTcpParams(host="1.2.3.4", port=502), 2) + [shared] = hass.data[DATA_MODBUS_CONNECTIONS].values() + + with patch.object(shared.connection, "close") as close: + await hass.config_entries.async_unload(one.entry_id) + await hass.async_block_till_done() + + # One consumer left, so the link it is still using stays up. + assert not close.called + assert hass.data[DATA_MODBUS_CONNECTIONS] + + await hass.config_entries.async_unload(two.entry_id) + await hass.async_block_till_done() + + assert close.called + assert not hass.data[DATA_MODBUS_CONNECTIONS] + + +async def test_one_entry_holding_twice_releases_twice( + hass: HomeAssistant, consumer: ConsumerFactory +) -> None: + """An entry with two devices on one link holds it twice. + + Counting entries rather than units would close the link under the second + device the moment the entry unloaded once. + """ + entry = consumer() + await hass.config_entries.async_setup(entry.entry_id) + + async_get_unit(hass, entry, ModbusTcpParams(host="1.2.3.4", port=502), 1) + async_get_unit(hass, entry, ModbusTcpParams(host="1.2.3.4", port=502), 2) + [shared] = hass.data[DATA_MODBUS_CONNECTIONS].values() + assert shared.consumers == 2 + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data[DATA_MODBUS_CONNECTIONS] + + +async def test_reloading_an_entry_reopens_the_connection( + hass: HomeAssistant, consumer: ConsumerFactory +) -> None: + """Nothing is held across a reload, so the entry gets a fresh connection. + + There is no grace period; that is what lets a stale connection be recovered. + """ + entry = consumer() + await hass.config_entries.async_setup(entry.entry_id) + async_get_unit(hass, entry, ModbusTcpParams(host="1.2.3.4", port=502), 1) + [first] = hass.data[DATA_MODBUS_CONNECTIONS].values() + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert not hass.data[DATA_MODBUS_CONNECTIONS] + + await hass.config_entries.async_setup(entry.entry_id) + async_get_unit(hass, entry, ModbusTcpParams(host="1.2.3.4", port=502), 1) + [second] = hass.data[DATA_MODBUS_CONNECTIONS].values() + + assert second.connection is not first.connection From 149f0316f2800b467ce2d126b4b8a008104cf350 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 23 Aug 2026 22:16:12 +0200 Subject: [PATCH 18/24] Use the non-deprecated vobject_instance in caldav (#179819) Co-authored-by: Claude --- homeassistant/components/caldav/coordinator.py | 14 +++++++------- homeassistant/components/caldav/todo.py | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index 65aec441be613c..d711e0bb810335 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -67,10 +67,10 @@ def _get_events( ) event_list = [] for event in vevent_list: - if not hasattr(event.instance, "vevent"): + if not hasattr(event.vobject_instance, "vevent"): _LOGGER.warning("Skipped event with missing 'vevent' property") continue - vevent = event.instance.vevent + vevent = event.vobject_instance.vevent if not self.is_matching(vevent, self.search): continue event_list.append( @@ -122,10 +122,10 @@ def _get_next_event( # and they would not be properly parsed using their original start/end dates. new_events = [] for event in results: - if not hasattr(event.instance, "vevent"): + if not hasattr(event.vobject_instance, "vevent"): _LOGGER.warning("Skipped event with missing 'vevent' property") continue - vevent = event.instance.vevent + vevent = event.vobject_instance.vevent for start_dt in vevent.getrruleset() or []: _start_of_today: date | datetime _start_of_tomorrow: datetime | date @@ -138,7 +138,7 @@ def _get_next_event( _start_of_tomorrow = start_of_tomorrow if _start_of_today <= start_dt < _start_of_tomorrow: new_event = event.copy() - new_vevent = new_event.instance.vevent # type: ignore[attr-defined] + new_vevent = new_event.vobject_instance.vevent # type: ignore[attr-defined] if hasattr(new_vevent, "dtend"): dur = new_vevent.dtend.value - new_vevent.dtstart.value new_vevent.dtend.value = start_dt + dur @@ -147,9 +147,9 @@ def _get_next_event( elif _start_of_tomorrow <= start_dt: break vevents = [ - event.instance.vevent + event.vobject_instance.vevent for event in results + new_events - if hasattr(event.instance, "vevent") + if hasattr(event.vobject_instance, "vevent") ] # dtstart can be a date or datetime depending if the event lasts a diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index 6f652339eb8f14..6a88445e8a54d4 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -73,8 +73,8 @@ def _get_todo_items(calendar: caldav.Calendar) -> list[TodoItem]: def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: """Convert a caldav Todo into a TodoItem.""" if ( - not hasattr(resource.instance, "vtodo") - or not (todo := resource.instance.vtodo) + not hasattr(resource.vobject_instance, "vtodo") + or not (todo := resource.vobject_instance.vtodo) or (uid := get_attr_value(todo, "uid")) is None or (summary := get_attr_value(todo, "summary")) is None ): From 74a4168e5d1511816e33b9dbbcd3e01621bc5cb3 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:32:16 +0200 Subject: [PATCH 19/24] Migrate UniFi Protect light discovery to the public API (#176570) --- .../components/unifiprotect/__init__.py | 2 +- .../components/unifiprotect/const.py | 1 + homeassistant/components/unifiprotect/data.py | 126 +++++++--- .../components/unifiprotect/light.py | 99 ++++++-- tests/components/unifiprotect/conftest.py | 24 +- tests/components/unifiprotect/test_light.py | 217 +++++++++++++++--- tests/components/unifiprotect/utils.py | 8 + 7 files changed, 391 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index d428b57763091d..ed76da0f2811ef 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -163,7 +163,7 @@ async def _async_setup_entry( # streams depend on it, so a failed prime retries instead of building # streamless cameras. try: - await data_service.api.update_public() + await data_service.async_update_public() except NotAuthorized as err: # A public 401 means a bad/revoked API key (independent of the private # session); route to reauth instead of retrying forever. diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 6f403f4d1a2727..a6f66023a31fb9 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -82,6 +82,7 @@ DISPATCH_ADD = "add_device" DISPATCH_ADOPT = "adopt_device" DISPATCH_CHANNELS = "new_camera_channels" +DISPATCH_PUBLIC_ADD = "public_add_device" EVENT_TYPE_FINGERPRINT_IDENTIFIED: Final = "identified" EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED: Final = "not_identified" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index a9760ecf0f96ff..1f532fa369704e 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -16,6 +16,7 @@ Camera, Event, EventType, + Light, ModelType, ProtectAdoptableDeviceModel, PTZPatrol, @@ -23,7 +24,7 @@ WSAction, WSSubscriptionMessage, ) -from uiprotect.data.public_devices import PublicCamera +from uiprotect.data.public_devices import PublicCamera, PublicLight from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.utils import log_event from uiprotect.websocket import WebsocketState @@ -46,6 +47,7 @@ DISPATCH_ADD, DISPATCH_ADOPT, DISPATCH_CHANNELS, + DISPATCH_PUBLIC_ADD, DOMAIN, ) from .utils import async_get_devices_by_type @@ -69,6 +71,31 @@ def _async_dispatch_id(entry: UFPConfigEntry, dispatch: str) -> str: return f"{DOMAIN}.{entry.entry_id}.{dispatch}" +def _pair_public_private[ + PublicDeviceT: PublicDeviceModel, + PrivateDeviceT: ProtectAdoptableDeviceModel, +]( + public_devices: dict[str, PublicDeviceT], + private_devices: dict[str, PrivateDeviceT], +) -> Generator[tuple[PublicDeviceT | None, PrivateDeviceT | None]]: + """Pair public-master devices with their private fill by shared id. + + The public map is the master list; the matching private device is attached + when present (hybrid) and ``None`` in public-only mode. An adopted private + device not (yet) mirrored publicly is yielded as ``(None, private)`` so the + caller can defer it. Devices not adopted by us are skipped on both sides. + """ + for device_id, public in public_devices.items(): + private = private_devices.get(device_id) + if private is not None and not private.is_adopted_by_us: + continue + yield public, private + for device_id, private in private_devices.items(): + if device_id in public_devices or not private.is_adopted_by_us: + continue + yield None, private + + class ProtectData: """Coordinate updates.""" @@ -96,6 +123,8 @@ def __init__( str, set[Callable[[PublicDeviceModel | None], None]] ] = defaultdict(set) self._pending_camera_ids: set[str] = set() + self._known_public_macs: set[str] = set() + self._public_baseline_taken = False self._unsubs: list[CALLBACK_TYPE] = [] self._auth_failures = 0 self.auth_retries = 0 @@ -106,6 +135,7 @@ def __init__( self.adopt_signal = _async_dispatch_id(entry, DISPATCH_ADOPT) self.add_signal = _async_dispatch_id(entry, DISPATCH_ADD) self.channels_signal = _async_dispatch_id(entry, DISPATCH_CHANNELS) + self.public_add_signal = _async_dispatch_id(entry, DISPATCH_PUBLIC_ADD) # PTZ patrol cache: camera_id -> list of patrols self.ptz_patrols: dict[str, list[PTZPatrol]] = {} @@ -162,15 +192,7 @@ def get_cameras(self, ignore_unadopted: bool = True) -> Generator[Camera]: def get_public_cameras( self, ) -> Generator[tuple[PublicCamera | None, Camera | None]]: - """Iterate cameras public-master with private-fill. - - The public bootstrap is the master list; the matching private camera is - paired by shared id when present (hybrid) and ``None`` in public-only - mode. An adopted private camera not (yet) mirrored into the public - bootstrap is yielded as ``(None, private)`` so the caller can defer it. - Adopted-filtering mirrors ``get_cameras`` whenever a private object is - available. - """ + """Yield ``(public, private)`` camera pairs (see _pair_public_private).""" api = self.api if not api.has_public_bootstrap: return @@ -179,16 +201,21 @@ def get_public_cameras( private_cameras: dict[str, Camera] = ( {} if api.is_public_only else api.bootstrap.cameras ) - public_cameras = api.public_bootstrap.cameras - for camera_id, public in public_cameras.items(): - private = private_cameras.get(camera_id) - if private is not None and not private.is_adopted_by_us: - continue - yield public, private - for camera_id, private in private_cameras.items(): - if camera_id in public_cameras or not private.is_adopted_by_us: - continue - yield None, private + yield from _pair_public_private(api.public_bootstrap.cameras, private_cameras) + + def get_public_lights( + self, + ) -> Generator[tuple[PublicLight | None, Light | None]]: + """Yield ``(public, private)`` light pairs (see _pair_public_private).""" + api = self.api + if not api.has_public_bootstrap: + return + # An API-key-only client never initializes the private bootstrap; + # accessing it would raise. + private_lights: dict[str, Light] = ( + {} if api.is_public_only else api.bootstrap.lights + ) + yield from _pair_public_private(api.public_bootstrap.lights, private_lights) async def async_load_ptz_patrols(self) -> None: """Load PTZ patrols for all PTZ cameras.""" @@ -250,6 +277,47 @@ def async_subscribe_public_events(self) -> None: """ self._unsubs.append(self.api.subscribe_events(self._async_process_public_event)) + async def async_update_public(self) -> None: + """Refresh the public bootstrap through the library. + + The first successful refresh fixes the add-dedup baseline: platforms + enumerate that snapshot at setup, so only devices appearing later are + offered through the add signal. Public-only mode only, matching the + dispatch gate: hybrid never dispatches adds. + """ + await self.api.update_public() + if self._public_baseline_taken: + return + self._public_baseline_taken = True + api = self.api + if not api.is_public_only or not api.has_public_bootstrap: + return + self._known_public_macs.update( + device.mac + for device in api.public_bootstrap.all_devices() + if isinstance(device, PublicDeviceModel) + ) + + @callback + def _async_dispatch_new_public_device(self, device: PublicDeviceModel) -> None: + """Offer a public device to the platforms, once per mac. + + Public-only mode only: hybrid discovers new devices through the private + adopt path, and a second add would clash on unique_id. Cameras are + excluded, the channels signal owns their (re-)enumeration. Dedup + happens here so platforms can add without their own duplicate checks. + """ + api = self.api + if ( + not api.is_public_only + or not api.has_public_bootstrap + or device.model is ModelType.CAMERA + or device.mac in self._known_public_macs + ): + return + self._known_public_macs.add(device.mac) + async_dispatcher_send(self._hass, self.public_add_signal, device) + @callback def _async_process_public_devices_ws_message( self, message: WSSubscriptionMessage @@ -279,6 +347,8 @@ def _async_process_public_devices_ws_message( if isinstance(new_obj, PublicDeviceModel): if new_obj.model is ModelType.CAMERA: self._async_reenumerate_camera_on_public_change(new_obj, message) + elif message.action is WSAction.ADD: + self._async_dispatch_new_public_device(new_obj) self._async_signal_public_update(new_obj.mac, new_obj) @callback @@ -362,22 +432,26 @@ def _async_public_ws_state_changed(self, state: WebsocketState) -> None: async def _async_resignal_after_public_resync(self) -> None: """Re-signal public entities once a fresh public snapshot is applied.""" try: - await self.api.update_public() + await self.async_update_public() except NotAuthorized: # A revoked API key cannot self-recover. self._entry.async_start_reauth(self._hass) return except (TimeoutError, ClientError, ServerDisconnectedError) as err: - # Transport errors retry on the next reconnect. - _LOGGER.debug("Public refresh after reconnect failed: %s", err) + # Retried on the next reconnect, but this now gates discovery of + # devices that appeared during the gap, so make it visible. + _LOGGER.warning("Public refresh after reconnect failed: %s", err) return self._async_process_public_updates() - # Existing subscriptions are refreshed above, but a camera that - # appeared (or gained streams) during the gap still needs its - # entities; the platform adds only the missing ones. + # A device that appeared during the gap gets no add frame, so re-offer + # everything; the dispatch helper drops what platforms already know. if self.api.has_public_bootstrap: for public in list(self.api.public_bootstrap.cameras.values()): async_dispatcher_send(self._hass, self.channels_signal, public) + if self.api.is_public_only: + for device in list(self.api.public_bootstrap.all_devices()): + if isinstance(device, PublicDeviceModel): + self._async_dispatch_new_public_device(device) @callback def _async_events_ws_state_changed(self, state: WebsocketState) -> None: diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index e77ca44b1294d7..dbe0214b1be7df 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -3,15 +3,23 @@ import logging from typing import Any, cast, override -from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel -from uiprotect.data.devices import LightDeviceSettings +from uiprotect.data import ( + Light, + ModelType, + ProtectAdoptableDeviceModel, + PublicDeviceModel, +) from uiprotect.data.public_devices import PublicLight from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .data import ProtectDeviceType, UFPConfigEntry +from .const import DEFAULT_BRAND +from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity from .utils import async_ufp_instance_command @@ -32,15 +40,41 @@ def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: if device.model is ModelType.LIGHT and device.can_write( data.api.bootstrap.auth_user ): - async_add_entities([ProtectLight(data, device)]) + light = cast(Light, device) + public = data.async_get_public_device(light) + async_add_entities( + [ + ProtectLight( + data, + public if isinstance(public, PublicLight) else None, + light, + ) + ] + ) + + @callback + def _add_new_public_device(device: PublicDeviceModel) -> None: + if isinstance(device, PublicLight): + async_add_entities([ProtectLight(data, device, None)]) data.async_subscribe_adopt(_add_new_device) - async_add_entities( - ProtectLight(data, device) - for device in data.get_by_types({ModelType.LIGHT}) - if device.can_write(data.api.bootstrap.auth_user) + entry.async_on_unload( + async_dispatcher_connect(hass, data.public_add_signal, _add_new_public_device) ) + entities: list[ProtectLight] = [] + for public, private in data.get_public_lights(): + if private is None: + # Public-only creates from the public object; hybrid defers to the + # adopt dispatch (its private fill would clash on unique_id). + if data.api.is_public_only: + entities.append(ProtectLight(data, public, None)) + continue + # Created even without a public mirror; unavailable until one arrives. + if private.can_write(data.api.bootstrap.auth_user): + entities.append(ProtectLight(data, public, private)) + async_add_entities(entities) + def unifi_brightness_to_hass(value: int) -> int: """Convert unifi brightness 1..6 to hass format 0..255.""" @@ -65,6 +99,34 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): # subscribes to the public devices websocket on this flag. _ufp_uses_public = True + def __init__( + self, + data: ProtectData, + public: PublicLight | None, + private: Light | None, + ) -> None: + """Initialize the light.""" + self._private = private + self._ufp_public_obj = public + # unique_id and device info derive from the base device, so hybrid must + # keep the private one to leave existing entities unchanged. + super().__init__(data, cast(ProtectDeviceType, private or public)) + + @callback + @override + def _async_set_device_info(self) -> None: + if self._private is not None: + super()._async_set_device_info() + return + # market_name/firmware/URL and the NVR link are private-only. + public = cast(PublicLight, self.device) + self._attr_device_info = DeviceInfo( + name=public.display_name, + model=public.type, + manufacturer=DEFAULT_BRAND, + connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, + ) + @callback @override def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: @@ -94,26 +156,13 @@ async def async_turn_on(self, **kwargs: Any) -> None: else: _LOGGER.debug("Turning on light") - await self.device.api.update_light_public( - self.device.id, - is_light_force_enabled=True, - light_device_settings=( - LightDeviceSettings( - is_indicator_enabled=self.device.light_device_settings.is_indicator_enabled, - led_level=led_level, - pir_duration=self.device.light_device_settings.pir_duration, - pir_sensitivity=self.device.light_device_settings.pir_sensitivity, - ) - if led_level is not None - else None - ), - ) + # Reachable only while available (public object present); the setter + # validates the level and writes through the light's own settings. + await cast(PublicLight, self._ufp_public_obj).set_light(True, led_level) @async_ufp_instance_command @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" _LOGGER.debug("Turning off light") - await self.device.api.update_light_public( - self.device.id, is_light_force_enabled=False - ) + await cast(PublicLight, self._ufp_public_obj).set_light(False) diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index f31c71aee05983..e593175342dcfb 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -1,6 +1,6 @@ """Fixtures and test data for UniFi Protect methods.""" -from collections.abc import Callable, Generator +from collections.abc import Callable, Generator, Iterator from datetime import datetime, timedelta from functools import partial from ipaddress import IPv4Address @@ -23,6 +23,7 @@ Liveview, ModelType, ProtectModelWithId, + PublicBootstrap, Sensor, SmartDetectObjectType, StateType, @@ -173,25 +174,38 @@ async def get_nvr(*args: Any, **kwargs: Any) -> NVR: # them in ``update_public()``; the integration reads them synchronously. Start # with empty collections; the ``update_public`` side effect (see ``mock_entry``) # primes the cameras from the private bootstrap. - client.public_bootstrap = Mock() + client.public_bootstrap = Mock(spec=PublicBootstrap) client.public_bootstrap.cameras = {} + client.public_bootstrap.lights = {} client.public_bootstrap.relays = {} client.public_bootstrap.sirens = {} client.public_bootstrap.arm_profiles = {} client.public_bootstrap.arm_mode = None - # Cameras resolve to their primed public model (see ``update_public`` in - # ``mock_entry``); other device types opt in via the ``setup_public_*`` - # helpers, so they default to no paired public object. + # Cameras and lights resolve to their primed public model (see + # ``update_public`` in ``mock_entry`` / ``setup_public_light``); other + # device types opt in via the ``setup_public_*`` helpers, so they default + # to no paired public object. def _public_bootstrap_get( model: ModelType, obj_id: str ) -> ProtectModelWithId | None: if model is ModelType.CAMERA: return client.public_bootstrap.cameras.get(obj_id) + if model is ModelType.LIGHT: + return client.public_bootstrap.lights.get(obj_id) return None client.public_bootstrap.get = Mock(side_effect=_public_bootstrap_get) + def _public_all_devices() -> Iterator[Mock]: + pb = client.public_bootstrap + yield from pb.cameras.values() + yield from pb.lights.values() + yield from pb.relays.values() + yield from pb.sirens.values() + + client.public_bootstrap.all_devices = _public_all_devices + async def get_camera_rtsps_streams( camera_id: str, *args: Any, **kwargs: Any ) -> RTSPSStreams | None: diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index b224e9ada64d64..4316feb85cb620 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -1,8 +1,11 @@ """Test the UniFi Protect light platform.""" -from unittest.mock import AsyncMock +from typing import Any +from unittest.mock import AsyncMock, Mock -from uiprotect.data import DeviceState, Light +import pytest +from uiprotect.data import DeviceState, Light, Permission, WSAction +from uiprotect.websocket import WebsocketState from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -29,6 +32,19 @@ ) +def _use_public_only_bootstrap(ufp: MockUFPFixture, *publics: Mock) -> None: + """Serve setup and resync public refreshes from a public-only bootstrap.""" + ufp.api.is_public_only = True + + async def _prime() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + pb.lights = {public.id: public for public in publics} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime) + + async def test_light_remove( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: @@ -141,23 +157,18 @@ async def test_light_brightness_none( async def test_light_turn_on( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity turn on.""" - - light._api = ufp.api - light.api.update_light_public = AsyncMock() + """Test light entity turn on (routes through the public setter).""" setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) - entity_id = "light.test_light" await hass.services.async_call( - "light", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + "light", "turn_on", {ATTR_ENTITY_ID: "light.test_light"}, blocking=True ) - assert light.api.update_light_public.called - light.api.update_light_public.assert_called_once_with( - light.id, is_light_force_enabled=True, light_device_settings=None + ufp.api.public_bootstrap.lights[light.id].set_light.assert_awaited_once_with( + True, None ) @@ -166,26 +177,21 @@ async def test_light_turn_on_with_brightness( ) -> None: """Test light entity turn on with brightness.""" - light._api = ufp.api - light.api.update_light_public = AsyncMock() - setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) - entity_id = "light.test_light" await hass.services.async_call( "light", "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + {ATTR_ENTITY_ID: "light.test_light", ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert light.api.update_light_public.called - call_kwargs = light.api.update_light_public.call_args[1] - assert call_kwargs["is_light_force_enabled"] is True - assert call_kwargs["light_device_settings"] is not None - assert call_kwargs["light_device_settings"].led_level == 3 # 128/255 * 6 ≈ 3 + # 128/255 * 6 ≈ 3 + ufp.api.public_bootstrap.lights[light.id].set_light.assert_awaited_once_with( + True, 3 + ) async def test_light_turn_off( @@ -193,19 +199,172 @@ async def test_light_turn_off( ) -> None: """Test light entity turn off.""" - light._api = ufp.api - light.api.update_light_public = AsyncMock() - setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) - entity_id = "light.test_light" await hass.services.async_call( - "light", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + "light", "turn_off", {ATTR_ENTITY_ID: "light.test_light"}, blocking=True ) - assert light.api.update_light_public.called - light.api.update_light_public.assert_called_once_with( - light.id, is_light_force_enabled=False + ufp.api.public_bootstrap.lights[light.id].set_light.assert_awaited_once_with(False) + + +async def test_light_setup_public_only( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + light: Light, +) -> None: + """In public-only mode lights are enumerated from the public bootstrap.""" + + public = make_public_light(light) + _use_public_only_bootstrap(ufp, public) + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + entity_id = "light.test_light" + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == light.mac + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + +async def test_light_added_after_setup_public_only( + hass: HomeAssistant, + ufp: MockUFPFixture, + light: Light, + caplog: pytest.LogCaptureFixture, +) -> None: + """In public-only mode a light added later is discovered from its frame. + + There is no private adopt path without a local user, so the public devices + websocket ``add`` frame is the only discovery signal. + """ + + _use_public_only_bootstrap(ufp) + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.LIGHT, 0, 0) + + # A new light appears on the public devices websocket. + public = make_public_light(light) + ufp.api.public_bootstrap.lights = {light.id: public} + msg = public_device_ws_message(public) + msg.action = WSAction.ADD + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + state = hass.states.get("light.test_light") + assert state + assert state.state != STATE_UNAVAILABLE + + # A re-delivered add frame (e.g. the light was removed and re-added while + # its entity is still registered) is skipped before the platform has to + # reject the duplicate unique_id with an error. + msg = public_device_ws_message(public) + msg.action = WSAction.ADD + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + assert "already exists" not in caplog.text + + +async def test_light_added_during_gap_public_only( + hass: HomeAssistant, + ufp: MockUFPFixture, + light: Light, + caplog: pytest.LogCaptureFixture, +) -> None: + """A light added while the websocket was down enumerates on reconnect. + + No add frame arrives for a light that appeared during the gap, so the + reconnect resync must dispatch it for enumeration itself. + """ + + first = make_public_light(light) + _use_public_only_bootstrap(ufp, first) + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + # A second light appears during the gap; the reconnect resync includes it. + second = make_public_light(light) + second.id = "gap-light" + second.mac = "FFEEDDCCBB02" + second.name = "Gap Light" + second.display_name = "Gap Light" + + _use_public_only_bootstrap(ufp, first, second) + ufp.devices_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + ufp.devices_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.LIGHT, 2, 2) + assert hass.states.get("light.gap_light") is not None + # The resync re-offers the first light too; the dedup must drop it. + assert "already exists" not in caplog.text + + +async def test_light_turn_on_with_brightness_public_only( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Turning on with brightness routes through the public setter in public-only.""" + + public = make_public_light(light) + _use_public_only_bootstrap(ufp, public) + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + await hass.services.async_call( + "light", + "turn_on", + {ATTR_ENTITY_ID: "light.test_light", ATTR_BRIGHTNESS: 128}, + blocking=True, ) + + # 128/255 * 6 ≈ 3 + public.set_light.assert_awaited_once_with(True, 3) + + +async def test_light_setup_no_perm( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """A light the auth user cannot write to gets no entity in hybrid mode.""" + + ufp.api.bootstrap.auth_user.all_permissions = [ + Permission.unifi_dict_to_dict({"rawPermission": "light:read:*"}) + ] + + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.LIGHT, 0, 0) + + +async def test_light_setup_defers_to_adopt_without_private( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Hybrid: a public light without its private object waits for the adopt. + + Creating it public-only would collide on unique_id with the entity the + adopt dispatch creates once the private object arrives. + """ + + light._api = ufp.api + ufp.api.public_bootstrap.lights = {light.id: make_public_light(light)} + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.LIGHT, 0, 0) + + await adopt_devices(hass, ufp, [light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + state = hass.states.get("light.test_light") + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 9407c4c150db07..c096c485347219 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -367,6 +367,9 @@ def make_public_light( public = Mock(spec=PublicLight) public.id = light.id public.mac = light.mac + public.name = light.name + public.display_name = light.display_name + public.type = light.type public.model = ModelType.LIGHT public.state = DeviceState[light.state.name] if state is None else state public.is_light_on = light.is_light_on if is_light_on is None else is_light_on @@ -543,6 +546,7 @@ def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: return public_bootstrap.get(model, obj_id) pb.get = _get + pb.all_devices = public_bootstrap.all_devices ufp.api.has_public_bootstrap = True ufp.api.public_bootstrap = pb @@ -562,14 +566,17 @@ def setup_public_light(ufp: MockUFPFixture) -> None: pb.arm_profiles = {} def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: + # One mock per id so command assertions hit the entity's cached object. if ( model is ModelType.LIGHT + and obj_id not in public_bootstrap.lights and (private := ufp.api.bootstrap.lights.get(obj_id)) is not None ): public_bootstrap.lights[obj_id] = make_public_light(private) return public_bootstrap.get(model, obj_id) pb.get = _get + pb.all_devices = public_bootstrap.all_devices ufp.api.has_public_bootstrap = True ufp.api.public_bootstrap = pb @@ -597,6 +604,7 @@ def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: return public_bootstrap.get(model, obj_id) pb.get = _get + pb.all_devices = public_bootstrap.all_devices ufp.api.has_public_bootstrap = True ufp.api.public_bootstrap = pb From 9d12e00dafd9febf16a7e3fcbb9658921f3db785 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:32:40 +0200 Subject: [PATCH 20/24] Migrate UniFi Protect camera config switches to the public API (#174963) --- .../components/unifiprotect/switch.py | 54 ++--- tests/components/unifiprotect/test_switch.py | 207 ++++++++++++++++++ tests/components/unifiprotect/utils.py | 36 ++- 3 files changed, 258 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index b6cd391a63a745..310ad7b963bfca 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -77,7 +77,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="status_light", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_led_status", - ufp_value="led_settings.is_enabled", + ufp_public_value="led_settings.is_enabled", ufp_set_method="set_status_light_public", ufp_perm=PermRequired.WRITE, ), @@ -96,7 +96,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="high_fps", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_highfps", - ufp_value="is_high_fps_enabled", + ufp_public_value="is_high_fps_enabled", ufp_set_method_fn=_set_highfps, ufp_perm=PermRequired.WRITE, ), @@ -114,7 +114,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: key="osd_name", translation_key="overlay_show_name", entity_category=EntityCategory.CONFIG, - ufp_value="osd_settings.is_name_enabled", + ufp_public_value="osd_settings.is_name_enabled", ufp_set_method="set_osd_name_public", ufp_perm=PermRequired.WRITE, ), @@ -122,7 +122,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: key="osd_date", translation_key="overlay_show_date", entity_category=EntityCategory.CONFIG, - ufp_value="osd_settings.is_date_enabled", + ufp_public_value="osd_settings.is_date_enabled", ufp_set_method="set_osd_date_public", ufp_perm=PermRequired.WRITE, ), @@ -130,7 +130,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: key="osd_logo", translation_key="overlay_show_logo", entity_category=EntityCategory.CONFIG, - ufp_value="osd_settings.is_logo_enabled", + ufp_public_value="osd_settings.is_logo_enabled", ufp_set_method="set_osd_logo_public", ufp_perm=PermRequired.WRITE, ), @@ -138,7 +138,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: key="osd_bitrate", translation_key="overlay_show_nerd_mode", entity_category=EntityCategory.CONFIG, - ufp_value="osd_settings.is_debug_enabled", + ufp_public_value="osd_settings.is_debug_enabled", ufp_set_method="set_osd_nerd_mode_public", ufp_perm=PermRequired.WRITE, ), @@ -165,8 +165,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_person", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_person", - ufp_value="is_person_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_person_detection_on", ufp_set_method="set_person_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -175,8 +174,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_vehicle", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_vehicle", - ufp_value="is_vehicle_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_vehicle_detection_on", ufp_set_method="set_vehicle_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -185,8 +183,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_animal", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_animal", - ufp_value="is_animal_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_animal_detection_on", ufp_set_method="set_animal_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -195,8 +192,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_package", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_package", - ufp_value="is_package_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_package_detection_on", ufp_set_method="set_package_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -205,8 +201,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_license_plate", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_license_plate", - ufp_value="is_license_plate_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_license_plate_detection_on", ufp_set_method="set_license_plate_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -215,8 +210,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_smoke", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_smoke", - ufp_value="is_smoke_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_smoke_detection_on", ufp_set_method="set_smoke_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -225,8 +219,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_co_alarm", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_co", - ufp_value="is_co_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_co_detection_on", ufp_set_method="set_co_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -235,8 +228,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_siren", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_siren", - ufp_value="is_siren_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_siren_detection_on", ufp_set_method="set_siren_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -245,8 +237,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_baby_cry", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_baby_cry", - ufp_value="is_baby_cry_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_baby_cry_detection_on", ufp_set_method="set_baby_cry_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -255,8 +246,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_speak", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_speaking", - ufp_value="is_speaking_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_speaking_detection_on", ufp_set_method="set_speaking_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -265,8 +255,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_bark", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_bark", - ufp_value="is_bark_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_bark_detection_on", ufp_set_method="set_bark_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -275,9 +264,8 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_car_alarm", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_car_alarm", - ufp_value="is_car_alarm_detection_on", - ufp_enabled="is_recording_enabled", # Public API renamed "car alarm" to "burglar"; internal model keeps the legacy name. + ufp_public_value="is_car_alarm_detection_on", ufp_set_method="set_burglar_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -286,8 +274,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_car_horn", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_car_horn", - ufp_value="is_car_horn_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_car_horn_detection_on", ufp_set_method="set_car_horn_detection_public", ufp_perm=PermRequired.WRITE, ), @@ -296,8 +283,7 @@ async def _set_hdr(obj: Camera, value: bool) -> None: translation_key="detections_glass_break", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_glass_break", - ufp_value="is_glass_break_detection_on", - ufp_enabled="is_recording_enabled", + ufp_public_value="is_glass_break_detection_on", ufp_set_method="set_glass_break_detection_public", ufp_perm=PermRequired.WRITE, ), diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index a0b3f5a371d8f7..fa0d054980db6d 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -1,5 +1,6 @@ """Test the UniFi Protect switch platform.""" +from typing import Any from unittest.mock import AsyncMock, Mock, call import pytest @@ -44,9 +45,11 @@ enable_entity, ids_from_device_description, init_entry, + make_public_camera, make_public_light, public_device_ws_message, remove_entities, + setup_public_camera, setup_public_light, ) @@ -194,6 +197,7 @@ async def test_switch_setup_camera_all( ) -> None: """Test switch entity setup for camera devices (all enabled feature flags).""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SWITCH, 17, 15) @@ -237,6 +241,7 @@ async def test_switch_setup_camera_none( ) -> None: """Test switch entity setup for camera devices (no enabled feature flags).""" + setup_public_camera(ufp) await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SWITCH, 8, 7) @@ -378,6 +383,7 @@ async def test_switch_camera_simple( ) -> None: """Tests all simple switches for cameras.""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SWITCH, 17, 15) @@ -408,6 +414,7 @@ async def test_switch_camera_highfps( ) -> None: """Tests High FPS switch for cameras.""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SWITCH, 17, 15) @@ -506,6 +513,7 @@ async def test_switch_camera_detections_public_api( SmartDetectAudioType.GLASS_BREAK, ] + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) assert description.ufp_set_method is not None @@ -529,6 +537,205 @@ async def test_switch_camera_detections_public_api( assert mock_method.call_count == 2 +async def test_switch_camera_status_light_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """Status light reads from the public object and refreshes on a public WS update.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SWITCHES if d.key == "status_light") + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) + assert hass.states.get(entity_id).state == STATE_OFF + + public = make_public_camera(doorbell, status_light=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize( + ("switch_key", "camera_kwarg"), + [ + ("osd_name", "osd_name"), + ("osd_date", "osd_date"), + ("osd_logo", "osd_logo"), + ("osd_bitrate", "osd_debug"), + ], +) +async def test_switch_camera_osd_public_value( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + switch_key: str, + camera_kwarg: str, +) -> None: + """Each OSD switch reads its own public osd_settings flag independently.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SWITCHES if d.key == switch_key) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) + assert hass.states.get(entity_id).state == STATE_OFF + + public = make_public_camera(doorbell, **{camera_kwarg: True}) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +# Only the switch under test is enabled; ``make_public_camera`` defaults both +# lists to every type, so each case pins both to keep the others off. +CAMERA_SWITCHES_DETECTION_READ = [ + ("smart_person", [SmartDetectObjectType.PERSON], []), + ("smart_vehicle", [SmartDetectObjectType.VEHICLE], []), + ("smart_animal", [SmartDetectObjectType.ANIMAL], []), + ("smart_package", [SmartDetectObjectType.PACKAGE], []), + ("smart_licenseplate", [SmartDetectObjectType.LICENSE_PLATE], []), + ("smart_smoke", [], [SmartDetectAudioType.SMOKE]), + ("smart_cmonx", [], [SmartDetectAudioType.CMONX]), + ("smart_siren", [], [SmartDetectAudioType.SIREN]), + ("smart_baby_cry", [], [SmartDetectAudioType.BABY_CRY]), + ("smart_speak", [], [SmartDetectAudioType.SPEAK]), + ("smart_bark", [], [SmartDetectAudioType.BARK]), + ("smart_car_alarm", [], [SmartDetectAudioType.BURGLAR]), + ("smart_car_horn", [], [SmartDetectAudioType.CAR_HORN]), + ("smart_glass_break", [], [SmartDetectAudioType.GLASS_BREAK]), +] + + +@pytest.mark.parametrize( + ("key", "object_types", "audio_types"), CAMERA_SWITCHES_DETECTION_READ +) +async def test_switch_camera_detection_public_value( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + key: str, + object_types: list[SmartDetectObjectType], + audio_types: list[SmartDetectAudioType], +) -> None: + """Each detection toggle reads its on/off state from its own public flag.""" + + doorbell.feature_flags.smart_detect_types = [ + SmartDetectObjectType.PERSON, + SmartDetectObjectType.VEHICLE, + SmartDetectObjectType.ANIMAL, + SmartDetectObjectType.PACKAGE, + SmartDetectObjectType.LICENSE_PLATE, + ] + doorbell.feature_flags.smart_detect_audio_types = [ + SmartDetectAudioType.SMOKE, + SmartDetectAudioType.CMONX, + SmartDetectAudioType.SIREN, + SmartDetectAudioType.BABY_CRY, + SmartDetectAudioType.SPEAK, + SmartDetectAudioType.BARK, + SmartDetectAudioType.BURGLAR, + SmartDetectAudioType.CAR_HORN, + SmartDetectAudioType.GLASS_BREAK, + ] + + setup_public_camera(ufp) + + async def _prime_without_camera() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_without_camera) + + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SWITCHES if d.key == key) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) + + all_off = make_public_camera(doorbell, object_types=[], audio_types=[]) + ufp.devices_ws_subscription(public_device_ws_message(all_off)) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_OFF + + public = make_public_camera( + doorbell, object_types=object_types, audio_types=audio_types + ) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_switch_camera_highfps_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """The high FPS switch reads video_mode from the public object.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SWITCHES if d.key == "high_fps") + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) + assert hass.states.get(entity_id).state == STATE_OFF + + public = make_public_camera(doorbell, video_mode=VideoMode.HIGH_FPS) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_switch_camera_detection_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """A migrated detection toggle is unavailable without a public object.""" + + async def _prime_without_camera() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_without_camera) + + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SWITCHES if d.key == "smart_person") + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_switch_camera_detection_available_with_recording_disabled( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """A migrated detection toggle stays available with recording disabled. + + Unlike the legacy private-only switch, the public detection toggles no + longer gate their availability on ``is_recording_enabled`` (breaking change). + """ + + doorbell.recording_settings.mode = RecordingMode.NEVER + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SWITCHES if d.key == "smart_person") + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + async def test_switch_camera_privacy( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ) -> None: diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index c096c485347219..afeccb5cef83e4 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -25,15 +25,18 @@ Sensor, SmartDetectAudioType, SmartDetectObjectType, + VideoMode, WSSubscriptionMessage, ) from uiprotect.data.bootstrap import ProtectDeviceRef from uiprotect.data.public_devices import ( PublicCamera, + PublicCameraLedSettings, PublicHdrMode, PublicLight, PublicLightDeviceSettings, PublicLightModeSettings, + PublicOsdSettings, PublicSensor, PublicSensorLeakSettings, PublicSensorMotionSettingsRead, @@ -425,6 +428,12 @@ def make_public_camera( camera: Camera, *, state: DeviceState | None = None, + status_light: bool = False, + osd_name: bool = False, + osd_date: bool = False, + osd_logo: bool = False, + osd_debug: bool = False, + video_mode: VideoMode | None = None, is_motion_detected: bool = False, is_smart_currently_detected: bool = False, is_person_currently_detected: bool = False, @@ -445,13 +454,19 @@ def make_public_camera( mic_volume: int | None = None, hdr_type: PublicHdrMode | None = None, ) -> Mock: - """Build a public-API camera mirroring a private camera's migrated fields. + """Build a public-API camera for a private camera's migrated fields. The stream tiers/mic/HDR back the migrated stream and select entities; the ``is_*`` flags back the migrated ``ufp_public_value`` detection paths and the ``smart_detect_settings`` types back the per-type ``ufp_public_enabled_fn`` - gates (default: all types enabled). ``mic_volume`` and ``hdr_type`` default to - values derived from the private fixture so the public mirror matches it. + gates (default: all types enabled). ``state``, ``video_mode``, ``mic_volume`` + and ``hdr_type`` (derived from the private ``hdr_mode_display``) mirror the + private camera when not overridden. + + ``status_light`` and the ``osd_*`` flags deliberately default to off instead + of mirroring, so a test overriding one sets a value the private object would + not produce and a wrong ``ufp_public_value``/``ufp_public_value_fn`` fails + the test. """ public = Mock(spec=PublicCamera) public.id = camera.id @@ -461,6 +476,14 @@ def make_public_camera( public.type = camera.type public.model = ModelType.CAMERA public.state = DeviceState[camera.state.name] if state is None else state + public.led_settings = PublicCameraLedSettings(is_enabled=status_light) + public.osd_settings = PublicOsdSettings( + is_name_enabled=osd_name, + is_date_enabled=osd_date, + is_logo_enabled=osd_logo, + is_debug_enabled=osd_debug, + ) + public.video_mode = camera.video_mode if video_mode is None else video_mode public.mic_volume = camera.mic_volume if mic_volume is None else mic_volume public.is_motion_detected = is_motion_detected public.is_smart_currently_detected = is_smart_currently_detected @@ -482,12 +505,15 @@ def make_public_camera( audio_types=_ALL_AUDIO_TYPES if audio_types is None else audio_types, ) # A Mock(spec) does not evaluate properties, so mirror the PublicCamera - # parity properties the migrated detection sensors gate on using the - # library's own logic. + # parity properties the migrated switches read and the detection sensors + # gate on, using the library's own logic. for name in ( + "is_high_fps_enabled", "is_person_detection_on", "is_vehicle_detection_on", "is_animal_detection_on", + "is_package_detection_on", + "is_license_plate_detection_on", "is_smoke_detection_on", "is_co_detection_on", "is_siren_detection_on", From c830734535a2951d33548d0f0afb121950db6400 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 23 Aug 2026 22:33:00 +0200 Subject: [PATCH 21/24] Don't restore non-KNX attributes for KNX sensors (#179932) Co-authored-by: Claude Opus 5 --- homeassistant/components/knx/sensor.py | 4 +- tests/components/knx/test_sensor.py | 64 +++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index fe34dc2976fc22..ba7f8b3fd9d26c 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -181,7 +181,9 @@ async def async_added_to_hass(self) -> None: ) ): self._attr_native_value = last_sensor_data.native_value - self._attr_extra_state_attributes.update(last_state.attributes) + # only restore KNX specific attributes - others may have changed + if (source := last_state.attributes.get(ATTR_SOURCE)) is not None: + self._attr_extra_state_attributes[ATTR_SOURCE] = source await super().async_added_to_hass() @override diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index 3557ae8f8167c9..7a18bce7a19b33 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -12,8 +12,19 @@ CONF_SYNC_STATE, ) from homeassistant.components.knx.schema import SensorSchema -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_UNKNOWN, Platform +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + CONF_NAME, + CONF_TYPE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant, State from . import KnxEntityGenerator @@ -99,6 +110,55 @@ async def test_sensor_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: assert not events +async def test_sensor_restore_ignores_stale_attributes( + hass: HomeAssistant, knx: KNXTestKit +) -> None: + """Test that restoring doesn't reapply attributes the entity doesn't provide.""" + fake_state = State( + "sensor.test", + "ignored in favour of native_value", + { + ATTR_SOURCE: knx.INDIVIDUAL_ADDRESS, + ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + ATTR_UNIT_OF_MEASUREMENT: "W", + }, + ) + extra_data = {"native_value": "42", "native_unit_of_measurement": None} + mock_restore_cache_with_extra_data(hass, [(fake_state, extra_data)]) + + await knx.setup_integration( + { + SensorSchema.PLATFORM: [ + { + CONF_NAME: "test", + CONF_STATE_ADDRESS: "2/2/2", + CONF_TYPE: "2byte_unsigned", # no unit or device class + CONF_SYNC_STATE: False, + }, + ] + } + ) + + knx.assert_state( + "sensor.test", + "42", + **{ATTR_SOURCE: knx.INDIVIDUAL_ADDRESS}, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=None, + ) + # a new telegram doesn't reintroduce the stale attributes either + await knx.receive_write("2/2/2", (0x00, 0x07)) + knx.assert_state( + "sensor.test", + "7", + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=None, + ) + + async def test_last_reported( hass: HomeAssistant, knx: KNXTestKit, From 2aa09bc9d40237ac2a20e4b54e26b71ea09f94b3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 23 Aug 2026 22:42:01 +0200 Subject: [PATCH 22/24] Add number platform to Midea (#179247) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/midea/__init__.py | 1 + .../components/midea/device_catalog.py | 3 + homeassistant/components/midea/number.py | 157 ++++++ homeassistant/components/midea/strings.json | 26 + .../midea/snapshots/test_number.ambr | 479 ++++++++++++++++++ tests/components/midea/test_number.py | 306 +++++++++++ 6 files changed, 972 insertions(+) create mode 100644 homeassistant/components/midea/number.py create mode 100644 tests/components/midea/snapshots/test_number.ambr create mode 100644 tests/components/midea/test_number.py diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index 8667bc3fcfaa14..989840a1393619 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -23,6 +23,7 @@ _PLATFORMS: list[Platform] = [ Platform.CLIMATE, Platform.HUMIDIFIER, + Platform.NUMBER, Platform.SELECT, ] diff --git a/homeassistant/components/midea/device_catalog.py b/homeassistant/components/midea/device_catalog.py index 140b259bbc582c..2e65f056eda5df 100644 --- a/homeassistant/components/midea/device_catalog.py +++ b/homeassistant/components/midea/device_catalog.py @@ -8,6 +8,9 @@ DeviceType.CC: "MDV Wi-Fi Controller", DeviceType.CF: "Heat Pump", DeviceType.FB: "Electric Heater", + DeviceType.C2: "Toilet", + DeviceType.CD: "Heat Pump Water Heater", + DeviceType.ED: "Water Drinking Appliance", DeviceType.X40: "Integrated Ceiling Fan", DeviceType.A1: "Dehumidifier", DeviceType.FA: "Fan", diff --git a/homeassistant/components/midea/number.py b/homeassistant/components/midea/number.py new file mode 100644 index 00000000000000..9104b7b27206e3 --- /dev/null +++ b/homeassistant/components/midea/number.py @@ -0,0 +1,157 @@ +"""Number for Midea.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast, override + +from midealocal.const import DeviceType +from midealocal.device import MideaDevice +from midealocal.devices.c2 import MideaC2Device + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import UnitOfTime, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import MideaConfigEntry, MideaEntity, midea_api_call + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class MideaNumberEntityDescription(NumberEntityDescription): + """Description for a Midea number entity.""" + + models: list[DeviceType] + max_value_fn: Callable[[MideaDevice], float | None] | None = None + + +NUMBERS: list[MideaNumberEntityDescription] = [ + MideaNumberEntityDescription( + key="dry_level", + translation_key="dry_level", + models=[DeviceType.C2], + native_min_value=0, + max_value_fn=lambda device: cast(MideaC2Device, device).max_dry_level, + native_step=1, + ), + MideaNumberEntityDescription( + key="water_temp_level", + translation_key="water_temp_level", + models=[DeviceType.C2], + native_min_value=0, + max_value_fn=lambda device: cast(MideaC2Device, device).max_water_temp_level, + native_step=1, + ), + MideaNumberEntityDescription( + key="seat_temp_level", + translation_key="seat_temp_level", + models=[DeviceType.C2], + native_min_value=0, + max_value_fn=lambda device: cast(MideaC2Device, device).max_seat_temp_level, + native_step=1, + ), + MideaNumberEntityDescription( + key="vacation_days", + translation_key="vacation_days", + models=[DeviceType.CD], + device_class=NumberDeviceClass.DURATION, + native_min_value=1, + native_max_value=360, + native_step=1, + native_unit_of_measurement=UnitOfTime.DAYS, + ), + MideaNumberEntityDescription( + key="water_hardness", + translation_key="water_hardness", + models=[DeviceType.ED], + native_min_value=0, + native_max_value=65535, + native_step=1, + ), + MideaNumberEntityDescription( + key="flushing_days", + translation_key="flushing_days", + models=[DeviceType.ED], + device_class=NumberDeviceClass.DURATION, + native_min_value=0, + native_max_value=99, + native_step=1, + native_unit_of_measurement=UnitOfTime.DAYS, + ), + MideaNumberEntityDescription( + key="leak_water_protection_value", + translation_key="leak_water_protection_value", + models=[DeviceType.ED], + device_class=NumberDeviceClass.VOLUME, + native_min_value=0, + native_max_value=2550, + native_step=50, + native_unit_of_measurement=UnitOfVolume.LITERS, + ), + MideaNumberEntityDescription( + key="heating_level", + translation_key="heating_level", + models=[DeviceType.FB], + native_min_value=1, + native_max_value=10, + native_step=1, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MideaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up numbers for device.""" + device = config_entry.runtime_data + + async_add_entities( + MideaNumber(device, description) + for description in NUMBERS + if device.device_type in description.models + # None means the model doesn't support this attribute at all, + # unlike select.py's key-presence check. + and device.attributes.get(description.key) is not None + ) + + +class MideaNumber(MideaEntity, NumberEntity): + """Represent a Midea number.""" + + entity_description: MideaNumberEntityDescription + + @property + @override + def native_max_value(self) -> float: + """Return the maximum value, reading it off the device if dynamic.""" + if self.entity_description.max_value_fn is not None: + value = self.entity_description.max_value_fn(self._device) + if value is not None: + return value + return super().native_max_value + + @property + @override + def native_value(self) -> float | None: + """Return the current value.""" + value = self._device.get_attribute(self.entity_description.key) + if not isinstance(value, (int, float)): + return None + return float(value) + + @override + def set_native_value(self, value: float) -> None: + """Set the value.""" + step = self.step + value = round(value / step) * step + with midea_api_call(): + self._device.set_attribute( + attr=self.entity_description.key, value=round(value) + ) diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index b5c855de31cf72..7b21e37329577e 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -106,6 +106,32 @@ "name": "Zone 2 thermostat" } }, + "number": { + "dry_level": { + "name": "Dry level" + }, + "flushing_days": { + "name": "Flushing days" + }, + "heating_level": { + "name": "Heating level" + }, + "leak_water_protection_value": { + "name": "Leak water protection value" + }, + "seat_temp_level": { + "name": "Seat temperature level" + }, + "vacation_days": { + "name": "Vacation days" + }, + "water_hardness": { + "name": "Water hardness" + }, + "water_temp_level": { + "name": "Water temperature level" + } + }, "select": { "detect_mode": { "name": "Detect mode", diff --git a/tests/components/midea/snapshots/test_number.ambr b/tests/components/midea/snapshots/test_number.ambr new file mode 100644 index 00000000000000..c63916ab5be6c2 --- /dev/null +++ b/tests/components/midea/snapshots/test_number.ambr @@ -0,0 +1,479 @@ +# serializer version: 1 +# name: test_number_state_snapshot[c2][number.bedroom_ac_dry_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 3, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.bedroom_ac_dry_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dry level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dry level', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dry_level', + 'unique_id': '12345678_dry_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_state_snapshot[c2][number.bedroom_ac_dry_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Dry level', + : 3, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.bedroom_ac_dry_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_number_state_snapshot[c2][number.bedroom_ac_seat_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 5, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.bedroom_ac_seat_temperature_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Seat temperature level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat temperature level', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'seat_temp_level', + 'unique_id': '12345678_seat_temp_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_state_snapshot[c2][number.bedroom_ac_seat_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Seat temperature level', + : 5, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.bedroom_ac_seat_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_number_state_snapshot[c2][number.bedroom_ac_water_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 5, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.bedroom_ac_water_temperature_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water temperature level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water temperature level', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_temp_level', + 'unique_id': '12345678_water_temp_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_state_snapshot[c2][number.bedroom_ac_water_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Water temperature level', + : 5, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.bedroom_ac_water_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_number_state_snapshot[cd][number.bedroom_ac_vacation_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 360, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.bedroom_ac_vacation_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Vacation days', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vacation days', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacation_days', + 'unique_id': '12345678_vacation_days', + 'unit_of_measurement': , + }) +# --- +# name: test_number_state_snapshot[cd][number.bedroom_ac_vacation_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Bedroom AC Vacation days', + : 360, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.bedroom_ac_vacation_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- +# name: test_number_state_snapshot[ed][number.bedroom_ac_flushing_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 99, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.bedroom_ac_flushing_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flushing days', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flushing days', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flushing_days', + 'unique_id': '12345678_flushing_days', + 'unit_of_measurement': , + }) +# --- +# name: test_number_state_snapshot[ed][number.bedroom_ac_flushing_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Bedroom AC Flushing days', + : 99, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.bedroom_ac_flushing_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.0', + }) +# --- +# name: test_number_state_snapshot[ed][number.bedroom_ac_leak_water_protection_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 2550, + : 0, + : , + : 50, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.bedroom_ac_leak_water_protection_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Leak water protection value', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Leak water protection value', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'leak_water_protection_value', + 'unique_id': '12345678_leak_water_protection_value', + 'unit_of_measurement': , + }) +# --- +# name: test_number_state_snapshot[ed][number.bedroom_ac_leak_water_protection_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'volume', + : 'Bedroom AC Leak water protection value', + : 2550, + : 0, + : , + : 50, + : , + }), + 'context': , + 'entity_id': 'number.bedroom_ac_leak_water_protection_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '500.0', + }) +# --- +# name: test_number_state_snapshot[ed][number.bedroom_ac_water_hardness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 65535, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.bedroom_ac_water_hardness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water hardness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water hardness', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_hardness', + 'unique_id': '12345678_water_hardness', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_state_snapshot[ed][number.bedroom_ac_water_hardness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Water hardness', + : 65535, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.bedroom_ac_water_hardness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- +# name: test_number_state_snapshot[fb][number.bedroom_ac_heating_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.bedroom_ac_heating_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heating level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating level', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_level', + 'unique_id': '12345678_heating_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_state_snapshot[fb][number.bedroom_ac_heating_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Heating level', + : 10, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.bedroom_ac_heating_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- diff --git a/tests/components/midea/test_number.py b/tests/components/midea/test_number.py new file mode 100644 index 00000000000000..d93fe47db76955 --- /dev/null +++ b/tests/components/midea/test_number.py @@ -0,0 +1,306 @@ +"""Tests for midea number.py.""" + +from collections.abc import Callable +from unittest.mock import patch + +from midealocal.const import DeviceType +from midealocal.devices.ac import DeviceAttributes as ACAttributes +from midealocal.devices.c2 import DeviceAttributes as C2Attributes +from midealocal.devices.cd import DeviceAttributes as CDAttributes +from midealocal.devices.ed import DeviceAttributes as EDAttributes +from midealocal.devices.fb import DeviceAttributes as FBAttributes +from midealocal.exceptions import SocketException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +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 setup_integration +from .conftest import DummyDevice, entity_entries +from .const import TEST_DEVICE_ID + +from tests.common import MockConfigEntry, snapshot_platform + + +def _c2_device() -> DummyDevice: + device = DummyDevice( + DeviceType.C2, + attributes={ + C2Attributes.dry_level: 1, + C2Attributes.water_temp_level: 2, + C2Attributes.seat_temp_level: 3, + }, + ) + device.max_dry_level = 3 + device.max_water_temp_level = 5 + device.max_seat_temp_level = 5 + return device + + +def _cd_device() -> DummyDevice: + return DummyDevice( + DeviceType.CD, + attributes={CDAttributes.vacation_days: 7}, + ) + + +def _ed_device() -> DummyDevice: + return DummyDevice( + DeviceType.ED, + attributes={ + EDAttributes.water_hardness: 120, + EDAttributes.flushing_days: 14, + EDAttributes.leak_water_protection_value: 500, + }, + ) + + +def _fb_device() -> DummyDevice: + return DummyDevice( + DeviceType.FB, + attributes={FBAttributes.heating_level: 5}, + ) + + +async def _assert_service_call( + hass: HomeAssistant, + entity_id: str, + value: float, + expected_calls: list[tuple], + device: DummyDevice, +) -> None: + """Call number.set_value and assert the fake device recorded the right call.""" + device.calls.clear() + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + assert device.calls == expected_calls + + +@pytest.mark.parametrize( + "device", + [ + pytest.param(_c2_device(), id="c2"), + pytest.param(_cd_device(), id="cd"), + pytest.param(_ed_device(), id="ed"), + pytest.param(_fb_device(), id="fb"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_state_snapshot( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device: DummyDevice, +) -> None: + """Test async_setup_entry creates the right number entities per device type.""" + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry, device) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_c2_number_dynamic_max_and_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test C2 number entities read their max from a device property and can be set.""" + device = _c2_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_dry_level"] + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert float(state.state) == 1 + assert state.attributes[ATTR_MIN] == 0 + assert state.attributes[ATTR_MAX] == 3 + assert state.attributes[ATTR_STEP] == 1 + + await _assert_service_call( + hass, + entity_entry.entity_id, + 2, + [("set_attribute", "dry_level", 2)], + device, + ) + + water_entry = entity_entries(hass, config_entry)[ + f"{TEST_DEVICE_ID}_water_temp_level" + ] + assert (water_state := hass.states.get(water_entry.entity_id)) is not None + assert water_state.attributes[ATTR_MAX] == 5 + + seat_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_seat_temp_level"] + assert (seat_state := hass.states.get(seat_entry.entity_id)) is not None + assert seat_state.attributes[ATTR_MAX] == 5 + + +async def test_cd_number_static_range( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test CD's vacation_days uses a static min/max/step range.""" + device = _cd_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_vacation_days"] + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert float(state.state) == 7 + assert state.attributes[ATTR_MIN] == 1 + assert state.attributes[ATTR_MAX] == 360 + + await _assert_service_call( + hass, + entity_entry.entity_id, + 30, + [("set_attribute", "vacation_days", 30)], + device, + ) + + +async def test_ed_number_entities( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test ED exposes water_hardness, flushing_days and leak_water_protection_value.""" + device = _ed_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry, device) + + entities = entity_entries(hass, config_entry) + assert f"{TEST_DEVICE_ID}_water_hardness" in entities + assert f"{TEST_DEVICE_ID}_flushing_days" in entities + assert f"{TEST_DEVICE_ID}_leak_water_protection_value" in entities + + leak_entry = entities[f"{TEST_DEVICE_ID}_leak_water_protection_value"] + await _assert_service_call( + hass, + leak_entry.entity_id, + 550, + [("set_attribute", "leak_water_protection_value", 550)], + device, + ) + + +async def test_fb_heating_level( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test FB's heating_level number entity.""" + device = _fb_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_heating_level"] + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert float(state.state) == 5 + + await _assert_service_call( + hass, + entity_entry.entity_id, + 8, + [("set_attribute", "heating_level", 8)], + device, + ) + + +async def test_number_unknown_when_attribute_not_numeric( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test native_value gracefully reports unknown if a later update clears it.""" + device = _fb_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_heating_level"] + assert (state := hass.states.get(entity_entry.entity_id)) + assert float(state.state) == 5 + + device.attributes[FBAttributes.heating_level] = None + device.notify_update({FBAttributes.heating_level: None}) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.state == "unknown" + + +async def test_number_not_created_when_attribute_missing( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test no number entity is created when the device does not report the attribute.""" + device = DummyDevice(DeviceType.FB, attributes={}) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry, device) + + assert entity_entries(hass, config_entry) == {} + + +async def test_number_not_created_for_other_device_type( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test no number entity is created for a device type without one (e.g. AC's fan_speed).""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 60, + }, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry, device) + + assert entity_entries(hass, config_entry) == {} + + +async def test_number_set_value_raises_on_device_communication_error( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test a device communication failure surfaces as a HomeAssistantError.""" + device = _fb_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_heating_level"] + + with ( + patch.object(device, "set_attribute", side_effect=SocketException("offline")), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_entry.entity_id, ATTR_VALUE: 3}, + blocking=True, + ) From 40ec3f7a1ed06abb2c91cc2b0836579d70199865 Mon Sep 17 00:00:00 2001 From: derekcentrico <1930094+derekcentrico@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:50:19 -0400 Subject: [PATCH 23/24] Filter duplicate bed objects from SleepIQ API before entity setup (#178682) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sleepiq/__init__.py | 55 +++++++ tests/components/sleepiq/test_init.py | 144 ++++++++++++++++++- 2 files changed, 198 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 742980259bfedc..1e7c5f38eeee28 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -6,6 +6,7 @@ from asyncsleepiq import ( AsyncSleepIQ, SleepIQAPIException, + SleepIQBed, SleepIQLoginException, SleepIQTimeoutException, ) @@ -92,6 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> b except SleepIQAPIException as err: raise ConfigEntryNotReady(str(err) or "Error reading from SleepIQ API") from err + _filter_duplicate_beds(gateway) await _async_migrate_unique_ids(hass, entry, gateway) coordinator = SleepIQDataUpdateCoordinator(hass, entry, gateway) @@ -120,6 +122,59 @@ async def async_unload_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +def _foundation_feature_count(bed: SleepIQBed) -> int: + """Count foundation features on a bed.""" + f = bed.foundation + return ( + len(f.lights) + + len(f.actuators) + + len(f.presets) + + len(f.foot_warmers) + + len(f.core_climates) + ) + + +def _filter_duplicate_beds(gateway: AsyncSleepIQ) -> None: + """Remove duplicate bed objects that share sleeper IDs. + + Groups beds whose sleeper-ID sets overlap and keeps the one with + the most foundation features so the real bed survives regardless + of API ordering. + """ + groups: dict[frozenset[str], list[str]] = {} + for bed_id, bed in gateway.beds.items(): + bed_sleeper_ids = frozenset(s.sleeper_id for s in bed.sleepers if s.sleeper_id) + if not bed_sleeper_ids: + continue + matched = None + for key in groups: + if key & bed_sleeper_ids: + matched = key + break + if matched is not None: + groups[matched].append(bed_id) + else: + groups[bed_sleeper_ids] = [bed_id] + + for bed_ids in groups.values(): + if len(bed_ids) < 2: + continue + best = max( + bed_ids, key=lambda bid: _foundation_feature_count(gateway.beds[bid]) + ) + for bed_id in bed_ids: + if bed_id != best: + _LOGGER.debug( + "Removing duplicate bed '%s' (id=%s), keeping '%s' (id=%s)" + " which has more foundation features", + gateway.beds[bed_id].name, + bed_id, + gateway.beds[best].name, + best, + ) + del gateway.beds[bed_id] + + async def _async_migrate_unique_ids( hass: HomeAssistant, entry: ConfigEntry, gateway: AsyncSleepIQ ) -> None: diff --git a/tests/components/sleepiq/test_init.py b/tests/components/sleepiq/test_init.py index 798eb20414a987..51e6c303718bda 100644 --- a/tests/components/sleepiq/test_init.py +++ b/tests/components/sleepiq/test_init.py @@ -3,11 +3,16 @@ from collections.abc import Callable from datetime import timedelta from http import HTTPStatus -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec from asyncsleepiq import ( + Side, + SleepData, SleepIQAPIException, + SleepIQBed, + SleepIQFoundation, SleepIQLoginException, + SleepIQSleeper, SleepIQTimeoutException, ) from freezegun.api import FrozenDateTimeFactory @@ -31,6 +36,8 @@ SLEEPER_L_ID, SLEEPER_L_NAME, SLEEPER_L_NAME_LOWER, + SLEEPER_R_ID, + SLEEPER_R_NAME, SLEEPIQ_CONFIG, setup_platform, ) @@ -214,3 +221,138 @@ async def test_unique_id_migration(hass: HomeAssistant, mock_asyncsleepiq) -> No sensor_sleep_number = ent_reg.async_get(ENTITY_SLEEP_NUMBER) assert sensor_sleep_number.unique_id == f"{SLEEPER_L_ID}_{SLEEP_NUMBER}" + + +def _make_controller() -> MagicMock: + """Build a bare controller bed with no foundation features.""" + controller = create_autospec(SleepIQBed) + controller.name = "Firmness Control" + controller.id = "ctrl_001" + controller.mac_addr = "AA:BB:CC:DD:EE:01" + controller.model = "Firmness Control, 360, Dual,Boxed" + controller.paused = False + + ctrl_sleeper_l = create_autospec(SleepIQSleeper) + ctrl_sleeper_l.side = Side.LEFT + ctrl_sleeper_l.name = SLEEPER_L_NAME + ctrl_sleeper_l.sleeper_id = SLEEPER_L_ID + ctrl_sleeper_l.in_bed = True + ctrl_sleeper_l.sleep_number = 40 + ctrl_sleeper_l.pressure = 1000 + ctrl_sleeper_l.sleep_data = SleepData( + duration=28800, + sleep_score=85, + heart_rate=60, + respiratory_rate=14, + hrv=68, + ) + + ctrl_sleeper_r = create_autospec(SleepIQSleeper) + ctrl_sleeper_r.side = Side.RIGHT + ctrl_sleeper_r.name = SLEEPER_R_NAME + ctrl_sleeper_r.sleeper_id = SLEEPER_R_ID + ctrl_sleeper_r.in_bed = False + ctrl_sleeper_r.sleep_number = 80 + ctrl_sleeper_r.pressure = 1400 + ctrl_sleeper_r.sleep_data = SleepData( + duration=25200, + sleep_score=78, + heart_rate=65, + respiratory_rate=15, + hrv=72, + ) + + controller.sleepers = [ctrl_sleeper_l, ctrl_sleeper_r] + controller.foundation = create_autospec(SleepIQFoundation) + controller.foundation.lights = [] + controller.foundation.actuators = [] + controller.foundation.presets = [] + controller.foundation.foot_warmers = [] + controller.foundation.core_climates = [] + return controller + + +async def test_duplicate_beds_filtered( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test that duplicate bed objects sharing sleeper IDs are filtered.""" + mock_asyncsleepiq.beds["ctrl_001"] = _make_controller() + + entry = await setup_platform(hass, "sensor") + assert entry.state is ConfigEntryState.LOADED + + sleeper_l_entities = [ + e + for e in er.async_entries_for_config_entry(entity_registry, entry.entry_id) + if SLEEPER_L_ID in e.unique_id + ] + sleeper_r_entities = [ + e + for e in er.async_entries_for_config_entry(entity_registry, entry.entry_id) + if SLEEPER_R_ID in e.unique_id + ] + + assert len(sleeper_l_entities) > 0 + assert len(sleeper_r_entities) > 0 + + bed_ids = list(mock_asyncsleepiq.beds) + assert "ctrl_001" not in bed_ids + assert BED_ID in bed_ids + + +async def test_duplicate_beds_controller_first( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test that the real bed survives even when the controller appears first.""" + real_bed = mock_asyncsleepiq.beds.pop(BED_ID) + mock_asyncsleepiq.beds["ctrl_001"] = _make_controller() + mock_asyncsleepiq.beds[BED_ID] = real_bed + + entry = await setup_platform(hass, "sensor") + assert entry.state is ConfigEntryState.LOADED + + bed_ids = list(mock_asyncsleepiq.beds) + assert BED_ID in bed_ids + assert "ctrl_001" not in bed_ids + + +async def test_duplicate_beds_none_sleeper_ids_not_filtered( + hass: HomeAssistant, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test that beds with None sleeper IDs are not falsely treated as duplicates.""" + ghost_bed = create_autospec(SleepIQBed) + ghost_bed.name = "Guest Bed" + ghost_bed.id = "ghost_001" + ghost_bed.mac_addr = "AA:BB:CC:DD:EE:02" + ghost_bed.model = "Guest" + ghost_bed.paused = False + + ghost_sleeper = create_autospec(SleepIQSleeper) + ghost_sleeper.side = Side.LEFT + ghost_sleeper.name = "Guest" + ghost_sleeper.sleeper_id = None + ghost_sleeper.in_bed = False + ghost_sleeper.sleep_number = 50 + ghost_sleeper.pressure = 1200 + ghost_sleeper.sleep_data = SleepData( + duration=0, sleep_score=0, heart_rate=0, respiratory_rate=0, hrv=0 + ) + + ghost_bed.sleepers = [ghost_sleeper] + ghost_bed.foundation = create_autospec(SleepIQFoundation) + ghost_bed.foundation.lights = [] + ghost_bed.foundation.actuators = [] + ghost_bed.foundation.presets = [] + ghost_bed.foundation.foot_warmers = [] + ghost_bed.foundation.core_climates = [] + + mock_asyncsleepiq.beds["ghost_001"] = ghost_bed + + entry = await setup_platform(hass, "sensor") + assert entry.state is ConfigEntryState.LOADED + assert "ghost_001" in mock_asyncsleepiq.beds From 027edb441b9e352d55781cbf6016172e3dc789dd Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:53:26 +0800 Subject: [PATCH 24/24] Switchbot Cloud: Add new supported devices[Curtain4] (#178783) --- .../switchbot_cloud/binary_sensor.py | 1 + .../components/switchbot_cloud/const.py | 3 + .../components/switchbot_cloud/sensor.py | 1 + .../fixtures/sensor_status.json | 11 ++ .../snapshots/test_binary_sensor.ambr | 102 ++++++++++++++++ .../snapshots/test_sensor.ambr | 110 ++++++++++++++++++ 6 files changed, 228 insertions(+) diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index cfd4e5fc191475..55141e5b832e92 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -114,6 +114,7 @@ class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription) ), "Curtain": (CALIBRATION_DESCRIPTION,), "Curtain3": (CALIBRATION_DESCRIPTION,), + "Curtain4": (CALIBRATION_DESCRIPTION,), "Roller Shade": (CALIBRATION_DESCRIPTION,), "Blind Tilt": (CALIBRATION_DESCRIPTION,), "Garage Door Opener": (DOOR_OPEN_DESCRIPTION,), diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 57a519aef327b8..7f1ae51f1988ba 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -164,6 +164,9 @@ class SwitchbotCloudDeviceConfig: "Curtain3": SwitchbotCloudDeviceConfig( True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.COVER) ), + "Curtain4": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.COVER) + ), "Roller Shade": SwitchbotCloudDeviceConfig( True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.COVER) ), diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index e7bb6f207faf1a..376f69eb0ec916 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -258,6 +258,7 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): ), "Curtain": (BATTERY_DESCRIPTION,), "Curtain3": (BATTERY_DESCRIPTION,), + "Curtain4": (BATTERY_DESCRIPTION,), "Roller Shade": (BATTERY_DESCRIPTION,), "Blind Tilt": (BATTERY_DESCRIPTION,), "Hub 3": ( diff --git a/tests/components/switchbot_cloud/fixtures/sensor_status.json b/tests/components/switchbot_cloud/fixtures/sensor_status.json index 389028792190d2..d54cf269b5722d 100644 --- a/tests/components/switchbot_cloud/fixtures/sensor_status.json +++ b/tests/components/switchbot_cloud/fixtures/sensor_status.json @@ -286,6 +286,17 @@ "version": "V6.3", "slidePosition": 50 }, + { + "deviceId": "07E6C4A290F2", + "deviceType": "Curtain4", + "hubDeviceId": "BBBBBBBBBB", + "calibrate": false, + "group": false, + "moving": false, + "battery": 100, + "version": "V6.3", + "slidePosition": 49 + }, { "deviceId": "F6D5B3A190E2", "deviceType": "Roller Shade", diff --git a/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr index 891c009be9fba5..3f1f8c46d2f20e 100644 --- a/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr @@ -254,6 +254,57 @@ 'state': 'on', }) # --- +# name: test_coordinator_data[Curtain4][binary_sensor.test_device_name_1_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_device_name_1_calibration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Calibration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Calibration', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'calibration', + 'unique_id': 'test-device-id-1_calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_coordinator_data[Curtain4][binary_sensor.test_device_name_1_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'test-device-name-1 Calibration', + }), + 'context': , + 'entity_id': 'binary_sensor.test_device_name_1_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_coordinator_data[Curtain][binary_sensor.test_device_name_1_calibration-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1886,6 +1937,57 @@ 'state': 'unknown', }) # --- +# name: test_no_coordinator_data[Curtain4][binary_sensor.test_device_name_1_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_device_name_1_calibration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Calibration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Calibration', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'calibration', + 'unique_id': 'test-device-id-1_calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_no_coordinator_data[Curtain4][binary_sensor.test_device_name_1_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'test-device-name-1 Calibration', + }), + 'context': , + 'entity_id': 'binary_sensor.test_device_name_1_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_no_coordinator_data[Curtain][binary_sensor.test_device_name_1_calibration-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr index 89827073ccec87..85ddbf7df35245 100644 --- a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -384,6 +384,61 @@ 'state': '100', }) # --- +# name: test_coordinator_data[Curtain4][sensor.test_device_name_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_name_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-device-id-1_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_coordinator_data[Curtain4][sensor.test_device_name_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'test-device-name-1 Battery', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.test_device_name_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_coordinator_data[Curtain][sensor.test_device_name_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -4097,6 +4152,61 @@ 'state': 'unknown', }) # --- +# name: test_no_coordinator_data[Curtain4][sensor.test_device_name_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_name_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-device-id-1_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_no_coordinator_data[Curtain4][sensor.test_device_name_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'test-device-name-1 Battery', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.test_device_name_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_no_coordinator_data[Curtain][sensor.test_device_name_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([