From e9be62fe77f221bf315411ec4d6514102a4f6c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Thu, 16 Jul 2026 11:38:54 +0200 Subject: [PATCH 01/13] Raise nobo_hub quality scale to gold (#176480) --- homeassistant/components/nobo_hub/icons.json | 17 ++++++++++++++ .../components/nobo_hub/manifest.json | 2 +- .../components/nobo_hub/quality_scale.yaml | 22 ++++++++++++------- 3 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/nobo_hub/icons.json diff --git a/homeassistant/components/nobo_hub/icons.json b/homeassistant/components/nobo_hub/icons.json new file mode 100644 index 00000000000000..74c20b18fe5b6f --- /dev/null +++ b/homeassistant/components/nobo_hub/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "select": { + "global_override": { + "default": "mdi:calendar-clock", + "state": { + "away": "mdi:account-arrow-right", + "comfort": "mdi:sofa", + "eco": "mdi:leaf" + } + }, + "week_profile": { + "default": "mdi:calendar-clock" + } + } + } +} diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index a098ec5a6607ca..3350742c38d512 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -15,6 +15,6 @@ "documentation": "https://www.home-assistant.io/integrations/nobo_hub", "integration_type": "hub", "iot_class": "local_push", - "quality_scale": "silver", + "quality_scale": "gold", "requirements": ["pynobo==1.9.0"] } diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 6ad1081c7d7b72..fb94595ed48f20 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -11,7 +11,7 @@ rules: dependency-transparency: done docs-actions: status: exempt - comment: Integration does not register custom actions. + comment: This integration does not register custom actions. docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -59,21 +59,27 @@ rules: docs-troubleshooting: done docs-use-cases: done dynamic-devices: done - entity-category: todo + entity-category: + status: exempt + comment: > + All entities are primary controls or measurements; none are configuration + or diagnostic entities that need a non-default entity category. entity-device-class: done - entity-disabled-by-default: todo - entity-translations: todo - exception-translations: todo - icon-translations: todo + entity-disabled-by-default: + status: exempt + comment: This integration has no entities that should be disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done reconfiguration-flow: done repair-issues: status: exempt - comment: Integration has no repair scenarios. + comment: This integration has no repair scenarios. stale-devices: done # Platinum async-dependency: done inject-websession: status: exempt - comment: Integration uses a local TCP socket (via pynobo); no HTTP client is used. + comment: This integration uses a local TCP socket (via pynobo); no HTTP client is used. strict-typing: todo From 2416d13dc5802e9b6cf06394cb108770c82fe611 Mon Sep 17 00:00:00 2001 From: Markus Tuominen <3738613+Markus98@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:05:15 +0200 Subject: [PATCH 02/13] Use plain Exception for scaffold config flow errors (#176597) --- .../templates/config_flow/integration/config_flow.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 19b2ab4066743a..226f375c2fbd70 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -8,7 +8,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN @@ -94,9 +93,9 @@ async def async_step_user( ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" From 02b7100c921cb3c0396f666d88a8bc7704681b88 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 16 Jul 2026 13:21:57 +0200 Subject: [PATCH 03/13] Bump aioshelly to 13.27.0 (#176605) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 02f5979e215826..93d326bc33fee5 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==13.26.2"], + "requirements": ["aioshelly==13.27.0"], "zeroconf": [ { "name": "shelly*", diff --git a/requirements_all.txt b/requirements_all.txt index 603043347a0b14..d52d230131166d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ aiorussound==5.0.1 aioruuvigateway==0.1.0 # homeassistant.components.shelly -aioshelly==13.26.2 +aioshelly==13.27.0 # homeassistant.components.skybell aioskybell==22.7.0 From d5e988a0215ca65d0f2f5c9dc9f318d8453531c7 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 16 Jul 2026 14:12:40 +0200 Subject: [PATCH 04/13] Refactor config flow with Exception in Fireflyy III (#176601) --- homeassistant/components/firefly_iii/config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/firefly_iii/config_flow.py b/homeassistant/components/firefly_iii/config_flow.py index 8f84da1c1cf641..f3684930bef0a8 100644 --- a/homeassistant/components/firefly_iii/config_flow.py +++ b/homeassistant/components/firefly_iii/config_flow.py @@ -15,7 +15,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -174,13 +173,13 @@ async def async_step_reconfigure( ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" -class FireflyClientTimeout(HomeAssistantError): +class FireflyClientTimeout(Exception): """Error to indicate a timeout occurred.""" From 06591972ad28e40c8abc366dc63dd0c0b6cb57ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Thu, 16 Jul 2026 14:14:47 +0200 Subject: [PATCH 05/13] Catch PynoboConnectionError at nobo_hub connection sites (#176604) --- homeassistant/components/nobo_hub/__init__.py | 6 ++-- .../components/nobo_hub/config_flow.py | 8 ++--- tests/components/nobo_hub/test_config_flow.py | 17 +++++----- tests/components/nobo_hub/test_init.py | 31 ++++++++++++++++--- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index faed74a2a16f32..1066d306349d3a 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -2,7 +2,7 @@ import logging -from pynobo import nobo +from pynobo import PynoboConnectionError, nobo from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -53,7 +53,7 @@ async def _connect(ip: str) -> nobo: try: hub = await _connect(stored_ip) - except OSError as err: + except PynoboConnectionError as err: # Stored IP may be stale - try UDP rediscovery to pick up a new # DHCP lease (or a hub that's been moved). discovered = await nobo.async_discover_hubs(serial=serial) @@ -66,7 +66,7 @@ async def _connect(ip: str) -> nobo: new_ip, _ = next(iter(discovered)) try: hub = await _connect(new_ip) - except OSError as rediscover_err: + except PynoboConnectionError as rediscover_err: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_connect", diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 8df9940493d3a9..ecbd23a7d487b4 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -3,7 +3,7 @@ import ipaddress from typing import TYPE_CHECKING, Any, override -from pynobo import nobo +from pynobo import PynoboConnectionError, nobo import voluptuous as vol from homeassistant.config_entries import ( @@ -313,14 +313,14 @@ async def _test_connection(self, serial: str, ip_address: str) -> str: raise NoboHubConnectError("invalid_ip") from err hub = nobo(serial=serial, ip=ip_address, discover=False, synchronous=False) # pynobo distinguishes the two failure modes: TCP-level errors - # (wrong IP, hub offline, port closed) raise OSError, while a - # successful TCP connection followed by a handshake REJECT + # (wrong IP, hub offline, port closed) raise PynoboConnectionError, + # while a successful TCP connection followed by a handshake REJECT # (serial mismatch) returns False. try: if not await hub.async_connect_hub(ip_address, serial): raise NoboHubConnectError("cannot_connect") return hub.hub_info["name"] - except OSError as err: + except PynoboConnectionError as err: raise NoboHubConnectError("cannot_connect_ip") from err finally: await hub.close() diff --git a/tests/components/nobo_hub/test_config_flow.py b/tests/components/nobo_hub/test_config_flow.py index a62e84bb0a92eb..14fe846426a6d9 100644 --- a/tests/components/nobo_hub/test_config_flow.py +++ b/tests/components/nobo_hub/test_config_flow.py @@ -1,8 +1,8 @@ """Test the Nobø Ecohub config flow.""" -import errno from unittest.mock import AsyncMock, PropertyMock, patch +from pynobo import PynoboConnectionError import pytest from homeassistant import config_entries @@ -407,7 +407,10 @@ async def test_configure_invalid_ip_address( ("connect_outcome", "expected_error"), [ ({"return_value": False}, "cannot_connect"), - ({"side_effect": ConnectionRefusedError(61, "")}, "cannot_connect_ip"), + ( + {"side_effect": PynoboConnectionError("Failed to connect")}, + "cannot_connect_ip", + ), ], ids=["serial_mismatch", "tcp_failure"], ) @@ -420,10 +423,10 @@ async def test_configure_cannot_connect( """Connect failures map to distinct error keys; retry recovers. pynobo's async_connect_hub returns False on a successful TCP connect - followed by a handshake REJECT (serial mismatch) and raises OSError - on TCP-level failure (wrong IP / hub offline). We surface these as - cannot_connect ("check serial number") and cannot_connect_ip - ("check IP address") respectively. + followed by a handshake REJECT (serial mismatch) and raises + PynoboConnectionError on TCP-level failure (wrong IP / hub offline). + We surface these as cannot_connect ("check serial number") and + cannot_connect_ip ("check IP address") respectively. """ with patch( "homeassistant.components.nobo_hub.config_flow.nobo.async_discover_hubs", @@ -818,7 +821,7 @@ async def test_reconfigure_flow_changes_ip( [ ( "192.168.1.200", - {"side_effect": ConnectionRefusedError(errno.ECONNREFUSED, "")}, + {"side_effect": PynoboConnectionError("Failed to connect")}, "cannot_connect_ip", 1, ), diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index 9a8d8c005b875b..f89c50b5bffc77 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -3,7 +3,7 @@ import logging from unittest.mock import MagicMock -from pynobo import nobo as pynobo_nobo +from pynobo import PynoboConnectionError, nobo as pynobo_nobo import pytest from homeassistant.components.nobo_hub.const import ( @@ -61,7 +61,7 @@ async def test_setup_rediscovery_updates_ip( """A failed direct connect falls back to rediscovery and persists the new IP.""" mock_config_entry.add_to_hass(hass) failing_hub = MagicMock(spec=pynobo_nobo) - failing_hub.connect.side_effect = OSError("Unreachable") + failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [failing_hub, mock_nobo_class.return_value] mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} @@ -83,7 +83,7 @@ async def test_setup_retries_when_rediscovery_finds_nothing( """Setup retries when stored IP fails and rediscovery is empty.""" mock_config_entry.add_to_hass(hass) failing_hub = MagicMock(spec=pynobo_nobo) - failing_hub.connect.side_effect = OSError("Unreachable") + failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [failing_hub] mock_nobo_class.async_discover_hubs.return_value = set() @@ -106,9 +106,9 @@ async def test_setup_retries_when_rediscovered_ip_also_fails( """Setup retries when both stored and rediscovered IPs fail.""" mock_config_entry.add_to_hass(hass) first_failing_hub = MagicMock(spec=pynobo_nobo) - first_failing_hub.connect.side_effect = OSError("Unreachable") + first_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") second_failing_hub = MagicMock(spec=pynobo_nobo) - second_failing_hub.connect.side_effect = OSError("Unreachable") + second_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub] mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} @@ -123,6 +123,27 @@ async def test_setup_retries_when_rediscovered_ip_also_fails( } +async def test_setup_does_not_catch_plain_os_error_on_rediscovered_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_class: MagicMock, +) -> None: + """A plain OSError from the rediscovered IP is not caught by the fallback.""" + mock_config_entry.add_to_hass(hass) + first_failing_hub = MagicMock(spec=pynobo_nobo) + first_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") + second_failing_hub = MagicMock(spec=pynobo_nobo) + second_failing_hub.connect.side_effect = OSError("boom") + mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub] + mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_nobo_class.call_count == 2 + + @pytest.mark.parametrize( ("stored_options", "expected_options"), [ From f58bc989a0a306ea61a4cefeb6cd8df9999b55c0 Mon Sep 17 00:00:00 2001 From: Markus Tuominen <3738613+Markus98@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:22:13 +0200 Subject: [PATCH 06/13] Remove redundant block_till_done from scaffold config flow tests (#176607) --- .../scaffold/templates/config_flow/tests/test_config_flow.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 66209f77e6a122..e2d8952396b1f9 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -30,7 +30,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" @@ -81,7 +80,6 @@ async def test_form_invalid_auth( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" @@ -133,7 +131,6 @@ async def test_form_cannot_connect( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" From a337bd026360fc6bb72755ee92d91585f08fae9d Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 16 Jul 2026 14:23:03 +0200 Subject: [PATCH 07/13] Refactor config flow with Exception in Portainer (#176600) --- homeassistant/components/portainer/config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index 2037ab45cac98f..8aa037d7b386f6 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( BooleanSelector, @@ -198,13 +197,13 @@ async def async_step_reconfigure( ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" -class PortainerTimeout(HomeAssistantError): +class PortainerTimeout(Exception): """Error to indicate a timeout occurred.""" From e63f227dd3a14e8f2fa1105c8be4d5f11ccc3f5c Mon Sep 17 00:00:00 2001 From: Amit Krishna <218109745+amitkio@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:55:07 +0530 Subject: [PATCH 08/13] Add diagnostics support to energieleser (#176552) --- .../components/energieleser/diagnostics.py | 25 ++++ .../energieleser/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 125 ++++++++++++++++++ .../energieleser/test_diagnostics.py | 55 ++++++++ 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100755 homeassistant/components/energieleser/diagnostics.py create mode 100755 tests/components/energieleser/snapshots/test_diagnostics.ambr create mode 100755 tests/components/energieleser/test_diagnostics.py diff --git a/homeassistant/components/energieleser/diagnostics.py b/homeassistant/components/energieleser/diagnostics.py new file mode 100755 index 00000000000000..792297e8c58bf9 --- /dev/null +++ b/homeassistant/components/energieleser/diagnostics.py @@ -0,0 +1,25 @@ +"""Diagnostics support for energieleser.""" + +import dataclasses +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.core import HomeAssistant + +from .coordinator import EnergieleserConfigEntry + +TO_REDACT = {CONF_DEVICE_ID, "fabrication_number"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: EnergieleserConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + device_data = coordinator.data + + device_data_dict = dataclasses.asdict(device_data) + + return async_redact_data(device_data_dict, TO_REDACT) diff --git a/homeassistant/components/energieleser/quality_scale.yaml b/homeassistant/components/energieleser/quality_scale.yaml index 0a83fc235811f1..ea4d7d13f73142 100644 --- a/homeassistant/components/energieleser/quality_scale.yaml +++ b/homeassistant/components/energieleser/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: todo diff --git a/tests/components/energieleser/snapshots/test_diagnostics.ambr b/tests/components/energieleser/snapshots/test_diagnostics.ambr new file mode 100755 index 00000000000000..863d1f582cb8c6 --- /dev/null +++ b/tests/components/energieleser/snapshots/test_diagnostics.ambr @@ -0,0 +1,125 @@ +# serializer version: 1 +# name: test_entry_diagnostics[gasleser] + dict({ + 'count': 603, + 'current_flow_rate': 0.01, + 'device_id': '**REDACTED**', + 'device_type': 'gasleser', + 'signal_strength_dbm': -51.0, + 'timestamp': 1776179005, + 'total_consumption': 37030.67, + }) +# --- +# name: test_entry_diagnostics[stromleser] + dict({ + 'device_id': '**REDACTED**', + 'device_type': 'stromleser', + 'energy_export': dict({ + 'unit': 'Wh', + 'value': 26561.0, + }), + 'energy_export_tariff_1': None, + 'energy_export_tariff_2': None, + 'energy_export_tariff_3': None, + 'energy_export_tariff_4': None, + 'energy_import': dict({ + 'unit': 'Wh', + 'value': 12345.0, + }), + 'energy_import_tariff_1': None, + 'energy_import_tariff_2': None, + 'energy_import_tariff_3': None, + 'energy_import_tariff_4': None, + 'pin_locked': False, + 'power_absolute': None, + 'power_active': dict({ + 'unit': 'W', + 'value': 8.16, + }), + 'power_export': None, + 'power_import': None, + 'power_l1': dict({ + 'unit': 'W', + 'value': 0.0, + }), + 'power_l2': dict({ + 'unit': 'W', + 'value': 0.0, + }), + 'power_l3': dict({ + 'unit': 'W', + 'value': 8.16, + }), + 'signal_strength_dbm': -51.0, + 'timestamp': 1776178480, + }) +# --- +# name: test_entry_diagnostics[waermeleser] + dict({ + 'device_id': '**REDACTED**', + 'device_type': 'waermeleser', + 'fabrication_number': '**REDACTED**', + 'flow_temperature': dict({ + 'unit': '°C', + 'value': 16.9, + }), + 'power': dict({ + 'unit': 'kW', + 'value': 2.31, + }), + 'return_temperature': dict({ + 'unit': '°C', + 'value': 19.6, + }), + 'signal_strength_dbm': -51.0, + 'temperature_difference': dict({ + 'unit': 'K', + 'value': 2.68, + }), + 'timestamp': 1747285200, + 'total_energy_t1': dict({ + 'unit': 'MWh', + 'value': 34.09, + }), + 'total_energy_t2': dict({ + 'unit': 'MWh', + 'value': 12.45, + }), + 'total_energy_t3': dict({ + 'unit': 'MWh', + 'value': 5.67, + }), + 'total_volume': dict({ + 'unit': 'm³', + 'value': 3561.23, + }), + 'volume_flow': dict({ + 'unit': 'l/h', + 'value': 1.23, + }), + }) +# --- +# name: test_entry_diagnostics[wasserleser] + dict({ + 'current_flow_rate': dict({ + 'unit': 'l/h', + 'value': 0.0, + }), + 'current_flow_rate_m3': dict({ + 'unit': 'm3/h', + 'value': 0.0, + }), + 'device_id': '**REDACTED**', + 'device_type': 'wasserleser', + 'signal_strength_dbm': -49.0, + 'timestamp': 1779276532, + 'today_consumption': dict({ + 'unit': 'm3', + 'value': 0.0, + }), + 'total_consumption': dict({ + 'unit': 'm3', + 'value': 123.755, + }), + }) +# --- diff --git a/tests/components/energieleser/test_diagnostics.py b/tests/components/energieleser/test_diagnostics.py new file mode 100755 index 00000000000000..61795c4615af7d --- /dev/null +++ b/tests/components/energieleser/test_diagnostics.py @@ -0,0 +1,55 @@ +"""Test energieleser diagnostics.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("device_fixture", "config_entry_fixture"), + [ + pytest.param( + "mock_stromleser_device", "mock_stromleser_config_entry", id="stromleser" + ), + pytest.param( + "mock_gasleser_device", "mock_gasleser_config_entry", id="gasleser" + ), + pytest.param( + "mock_waermeleser_device", + "mock_waermeleser_config_entry", + id="waermeleser", + ), + pytest.param( + "mock_wasserleser_device", + "mock_wasserleser_config_entry", + id="wasserleser", + ), + ], +) +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_energieleser_client: AsyncMock, + device_fixture: str, + config_entry_fixture: str, + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + device = request.getfixturevalue(device_fixture) + config_entry = request.getfixturevalue(config_entry_fixture) + + mock_energieleser_client.get_device.return_value = device + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot From 00be4a89049e67d6d76414ddd18f6b913a02dd49 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Thu, 16 Jul 2026 16:44:48 +0200 Subject: [PATCH 09/13] Bump python-duco-connectivity to 0.9.0 (#176615) --- homeassistant/components/duco/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index f3806627f2a714..49ee92e04bd451 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.8.0"], + "requirements": ["python-duco-connectivity==0.9.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/requirements_all.txt b/requirements_all.txt index d52d230131166d..e7521e0cb174ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2655,7 +2655,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.8.0 +python-duco-connectivity==0.9.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 From a55511f3fb008e6669326a14f16ad7555e85b5ce Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 16 Jul 2026 16:48:47 +0200 Subject: [PATCH 10/13] Remove Modbus Connection integration (#176618) Co-authored-by: Claude Fable 5 --- .strict-typing | 1 - CODEOWNERS | 2 - .../components/modbus_connection/__init__.py | 99 ------------ .../modbus_connection/config_flow.py | 126 --------------- .../components/modbus_connection/const.py | 21 --- .../modbus_connection/exceptions.py | 25 --- .../modbus_connection/manifest.json | 13 -- .../modbus_connection/quality_scale.yaml | 119 --------------- .../components/modbus_connection/strings.json | 62 -------- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - mypy.ini | 10 -- requirements_all.txt | 3 - .../components/modbus_connection/__init__.py | 1 - .../components/modbus_connection/conftest.py | 63 -------- .../modbus_connection/test_config_flow.py | 143 ------------------ .../components/modbus_connection/test_init.py | 128 ---------------- 17 files changed, 823 deletions(-) delete mode 100644 homeassistant/components/modbus_connection/__init__.py delete mode 100644 homeassistant/components/modbus_connection/config_flow.py delete mode 100644 homeassistant/components/modbus_connection/const.py delete mode 100644 homeassistant/components/modbus_connection/exceptions.py delete mode 100644 homeassistant/components/modbus_connection/manifest.json delete mode 100644 homeassistant/components/modbus_connection/quality_scale.yaml delete mode 100644 homeassistant/components/modbus_connection/strings.json delete mode 100644 tests/components/modbus_connection/__init__.py delete mode 100644 tests/components/modbus_connection/conftest.py delete mode 100644 tests/components/modbus_connection/test_config_flow.py delete mode 100644 tests/components/modbus_connection/test_init.py diff --git a/.strict-typing b/.strict-typing index 400f8d1f32ed0c..8f1239d4566556 100644 --- a/.strict-typing +++ b/.strict-typing @@ -383,7 +383,6 @@ homeassistant.components.min_max.* homeassistant.components.minecraft_server.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* -homeassistant.components.modbus_connection.* homeassistant.components.modem_callerid.* homeassistant.components.mold_indicator.* homeassistant.components.monzo.* diff --git a/CODEOWNERS b/CODEOWNERS index 93d8fcdbefdb58..8ce2921396d0c9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1148,8 +1148,6 @@ CLAUDE.md @home-assistant/core /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core -/homeassistant/components/modbus_connection/ @home-assistant/core -/tests/components/modbus_connection/ @home-assistant/core /homeassistant/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob /homeassistant/components/modern_forms/ @wonderslug diff --git a/homeassistant/components/modbus_connection/__init__.py b/homeassistant/components/modbus_connection/__init__.py deleted file mode 100644 index c09aca8ba8a329..00000000000000 --- a/homeassistant/components/modbus_connection/__init__.py +++ /dev/null @@ -1,99 +0,0 @@ -"""The Modbus Connection integration.""" - -from collections.abc import Mapping -from typing import Any, cast - -from modbus_connection import ModbusConnection, ModbusError, ModbusUnit -from modbus_connection.tmodbus import connect_serial, connect_tcp - -from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - DOMAIN, -) -from .exceptions import ConnectionNotReady - -__all__ = ["ConnectionNotReady", "async_get_unit"] - -type ModbusConnectionConfigEntry = ConfigEntry[ModbusConnection] - - -async def _async_open(data: Mapping[str, Any]) -> ModbusConnection: - """Open the connection described by ``data`` (transport parameters). - - Shared by config-entry setup and the config flow's validation; the caller - owns the returned connection and closes it. - """ - if data[CONF_TYPE] == CONNECTION_SERIAL: - return await connect_serial( - data[CONF_DEVICE], - baudrate=data[CONF_BAUDRATE], - bytesize=data[CONF_BYTESIZE], - parity=data[CONF_PARITY], - stopbits=data[CONF_STOPBITS], - ) - return await connect_tcp(data[CONF_HOST], port=data[CONF_PORT]) - - -async def async_setup_entry( - hass: HomeAssistant, entry: ModbusConnectionConfigEntry -) -> bool: - """Set up a Modbus connection from a config entry.""" - try: - connection = await _async_open(entry.data) - except ModbusError as err: - raise ConfigEntryNotReady(f"Could not open Modbus connection: {err}") from err - - entry.runtime_data = connection - - # The connection is transient and does not self-reconnect: on a drop, reload - # this entry. HA's ConfigEntryNotReady retry is the reconnect backoff. - entry.async_on_unload( - connection.on_connection_lost( - lambda: hass.config_entries.async_schedule_reload(entry.entry_id) - ) - ) - - return True - - -async def async_unload_entry( - hass: HomeAssistant, entry: ModbusConnectionConfigEntry -) -> bool: - """Unload a config entry and close the owned connection.""" - await entry.runtime_data.close() - return True - - -@callback -def async_get_unit( - hass: HomeAssistant, connection_entry_id: str, unit_id: int -) -> ModbusUnit: - """Return a Modbus unit on a shared connection. - - Consumer integrations call this to borrow a ``ModbusUnit`` bound to their - unit ID; the ``ModbusConnection`` itself never leaves this integration. - - Raises ``ValueError`` if ``connection_entry_id`` is unknown or does not point - at a ``modbus_connection`` entry (a programming error in the consumer). Raises - ``ConnectionNotReady`` if that entry exists but is not loaded; it is a - ``ConfigEntryNotReady``, so a consumer can let it propagate from its own - ``async_setup_entry`` to get Home Assistant's setup retry. - """ - entry = cast( - "ModbusConnectionConfigEntry | None", - hass.config_entries.async_get_entry(connection_entry_id), - ) - if entry is None or entry.domain != DOMAIN: - raise ValueError(f"{connection_entry_id} is not a modbus_connection entry") - if entry.state is not ConfigEntryState.LOADED: - raise ConnectionNotReady(connection_entry_id) - return entry.runtime_data.for_unit(unit_id) diff --git a/homeassistant/components/modbus_connection/config_flow.py b/homeassistant/components/modbus_connection/config_flow.py deleted file mode 100644 index dd0e3adc8ae508..00000000000000 --- a/homeassistant/components/modbus_connection/config_flow.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Config flow for the Modbus Connection integration.""" - -from typing import Any, override - -from modbus_connection import ModbusError -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.helpers.selector import ( - SelectSelector, - SelectSelectorConfig, - SelectSelectorMode, - SerialPortSelector, -) - -from . import _async_open -from .const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - CONNECTION_TCP, - DEFAULT_BAUDRATE, - DEFAULT_BYTESIZE, - DEFAULT_PARITY, - DEFAULT_PORT, - DEFAULT_STOPBITS, - DOMAIN, -) - -STEP_MODBUS_TCP = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( - vol.Coerce(int), vol.Range(min=1, max=65535) - ), - } -) - -# SerialPortSelector lists local serial ports and network serial proxies. -STEP_SERIAL = vol.Schema( - { - vol.Required(CONF_DEVICE): SerialPortSelector(), - vol.Required(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - vol.Required(CONF_PARITY, default=DEFAULT_PARITY): SelectSelector( - SelectSelectorConfig( - options=["n", "e", "o"], - translation_key="parity", - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Required(CONF_STOPBITS, default=DEFAULT_STOPBITS): vol.In([1, 2]), - vol.Required(CONF_BYTESIZE, default=DEFAULT_BYTESIZE): vol.In([7, 8]), - } -) - - -class ModbusConnectionConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Modbus Connection.""" - - VERSION = 1 - - @override - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Let the user choose the transport.""" - return self.async_show_menu( - step_id="user", - menu_options=["modbus_tcp", "serial"], - ) - - async def async_step_modbus_tcp( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure a Modbus TCP / RTU-over-TCP connection.""" - errors: dict[str, str] = {} - if user_input is not None: - data = {CONF_TYPE: CONNECTION_TCP, **user_input} - # Dedupe before opening: most Modbus devices reject a second client. - self._async_abort_entries_match(data) - if not (errors := await self._async_validate(data)): - return self.async_create_entry( - title=f"{data[CONF_HOST]}:{data[CONF_PORT]}", data=data - ) - return self.async_show_form( - step_id="modbus_tcp", data_schema=STEP_MODBUS_TCP, errors=errors - ) - - async def async_step_serial( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure a Modbus serial (RTU) connection, incl. network serial proxies.""" - errors: dict[str, str] = {} - if user_input is not None: - data = { - CONF_TYPE: CONNECTION_SERIAL, - **user_input, - # Store the uppercase parity code the connection expects. - CONF_PARITY: user_input[CONF_PARITY].upper(), - } - # A serial link is identified by its device path alone, regardless of - # baud rate and other line settings. - self._async_abort_entries_match( - {CONF_TYPE: CONNECTION_SERIAL, CONF_DEVICE: data[CONF_DEVICE]} - ) - if not (errors := await self._async_validate(data)): - return self.async_create_entry(title=data[CONF_DEVICE], data=data) - return self.async_show_form( - step_id="serial", data_schema=STEP_SERIAL, errors=errors - ) - - async def _async_validate(self, data: dict[str, Any]) -> dict[str, str]: - """Validate by actually opening the connection; return form errors.""" - try: - connection = await _async_open(data) - except ModbusError: - if data[CONF_TYPE] == CONNECTION_SERIAL: - return {"base": "cannot_open_serial_port"} - return {"base": "cannot_connect"} - await connection.close() - return {} diff --git a/homeassistant/components/modbus_connection/const.py b/homeassistant/components/modbus_connection/const.py deleted file mode 100644 index 369ecc4c5a09a8..00000000000000 --- a/homeassistant/components/modbus_connection/const.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Constants for the Modbus Connection integration.""" - -from typing import Final - -DOMAIN: Final = "modbus_connection" - -# Transport selection (stored under homeassistant.const.CONF_TYPE). -CONNECTION_TCP: Final = "tcp" -CONNECTION_SERIAL: Final = "serial" - -# Serial-only options. -CONF_BAUDRATE: Final = "baudrate" -CONF_BYTESIZE: Final = "bytesize" -CONF_PARITY: Final = "parity" -CONF_STOPBITS: Final = "stopbits" - -DEFAULT_PORT: Final = 502 -DEFAULT_BAUDRATE: Final = 9600 -DEFAULT_BYTESIZE: Final = 8 -DEFAULT_PARITY: Final = "n" -DEFAULT_STOPBITS: Final = 1 diff --git a/homeassistant/components/modbus_connection/exceptions.py b/homeassistant/components/modbus_connection/exceptions.py deleted file mode 100644 index 5c1ee68134b989..00000000000000 --- a/homeassistant/components/modbus_connection/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Exceptions for the Modbus Connection integration.""" - -from modbus_connection import ModbusError - -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import DOMAIN - - -class ConnectionNotReady(ConfigEntryNotReady, ModbusError): - """The shared Modbus connection is missing or not loaded. - - Raised by ``async_get_unit``. It is a ``ConfigEntryNotReady`` so a consumer - integration can let it propagate from its own ``async_setup_entry`` to get - Home Assistant's setup-retry behaviour, and a ``ModbusError`` so it is also - catchable with the library's error type. - """ - - def __init__(self, connection_entry_id: str) -> None: - """Initialize the error.""" - super().__init__( - translation_domain=DOMAIN, - translation_key="connection_not_ready", - ) - self.connection_entry_id = connection_entry_id diff --git a/homeassistant/components/modbus_connection/manifest.json b/homeassistant/components/modbus_connection/manifest.json deleted file mode 100644 index 156d5f3e45a874..00000000000000 --- a/homeassistant/components/modbus_connection/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "domain": "modbus_connection", - "name": "Modbus Connection", - "codeowners": ["@home-assistant/core"], - "config_flow": true, - "dependencies": ["usb"], - "documentation": "https://www.home-assistant.io/integrations/modbus_connection", - "integration_type": "hub", - "iot_class": "local_polling", - "loggers": ["modbus_connection", "tmodbus"], - "quality_scale": "bronze", - "requirements": ["modbus-connection[tmodbus]==3.7.0"] -} diff --git a/homeassistant/components/modbus_connection/quality_scale.yaml b/homeassistant/components/modbus_connection/quality_scale.yaml deleted file mode 100644 index 6eb47cc6c8b681..00000000000000 --- a/homeassistant/components/modbus_connection/quality_scale.yaml +++ /dev/null @@ -1,119 +0,0 @@ -rules: - # Bronze - action-setup: - status: exempt - comment: This integration does not register any service actions. - appropriate-polling: - status: exempt - comment: | - This integration does not poll. It owns a connection and hands out units; - consumer integrations poll through their own coordinators. - brands: done - common-modules: done - config-flow: done - config-flow-test-coverage: done - dependency-transparency: done - docs-actions: - status: exempt - comment: This integration does not register any service actions. - docs-conditions: - status: exempt - comment: This integration does not provide any conditions. - docs-high-level-description: done - docs-installation-instructions: done - docs-removal-instructions: done - docs-triggers: - status: exempt - comment: This integration does not provide any triggers. - entity-event-setup: - status: exempt - comment: This integration provides no entities. - entity-unique-id: - status: exempt - comment: This integration provides no entities. - has-entity-name: - status: exempt - comment: This integration provides no entities. - runtime-data: done - test-before-configure: done - test-before-setup: done - unique-config-entry: done - # Silver - action-exceptions: - status: exempt - comment: This integration does not register any service actions. - config-entry-unloading: done - docs-configuration-parameters: done - docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: This integration provides no entities. - integration-owner: done - log-when-unavailable: - status: exempt - comment: | - This integration provides no entities; availability is surfaced to - consumers via on_connection_lost and failing reads. - parallel-updates: - status: exempt - comment: This integration provides no entity platforms. - reauthentication-flow: - status: exempt - comment: A Modbus link has no authentication. - test-coverage: done - # Gold - devices: - status: exempt - comment: This integration provides connections, not devices or entities. - diagnostics: todo - discovery: - status: exempt - comment: Modbus links are not discoverable. - discovery-update-info: - status: exempt - comment: Modbus links are not discoverable. - docs-data-update: - status: exempt - comment: This integration provides no entities to update. - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: - status: exempt - comment: This integration is a connection provider, not a device integration. - docs-supported-functions: - status: exempt - comment: This integration provides no entities. - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: - status: exempt - comment: This integration provides no devices. - entity-category: - status: exempt - comment: This integration provides no entities. - entity-device-class: - status: exempt - comment: This integration provides no entities. - entity-disabled-by-default: - status: exempt - comment: This integration provides no entities. - entity-translations: - status: exempt - comment: This integration provides no entities. - exception-translations: todo - icon-translations: - status: exempt - comment: This integration provides no entities. - reconfiguration-flow: todo - repair-issues: - status: exempt - comment: No repairable issues are raised. - stale-devices: - status: exempt - comment: This integration provides no devices. - # Platinum - async-dependency: done - inject-websession: - status: exempt - comment: This integration talks Modbus, not HTTP. - strict-typing: done diff --git a/homeassistant/components/modbus_connection/strings.json b/homeassistant/components/modbus_connection/strings.json deleted file mode 100644 index a71d59af83bf30..00000000000000 --- a/homeassistant/components/modbus_connection/strings.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "cannot_open_serial_port": "Failed to open the serial port" - }, - "step": { - "modbus_tcp": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" - }, - "data_description": { - "host": "The hostname or IP address of the Modbus gateway or device.", - "port": "The TCP port the Modbus gateway listens on (default 502)." - }, - "title": "Modbus TCP" - }, - "serial": { - "data": { - "baudrate": "Baud rate", - "bytesize": "Byte size", - "device": "[%key:common::config_flow::data::device%]", - "parity": "Parity", - "stopbits": "Stop bits" - }, - "data_description": { - "baudrate": "The serial baud rate the device communicates at.", - "bytesize": "The number of data bits.", - "device": "The serial port the Modbus device is connected to, e.g. /dev/ttyUSB0.", - "parity": "The serial parity.", - "stopbits": "The number of stop bits." - }, - "title": "Serial connection" - }, - "user": { - "description": "How is the Modbus network connected?", - "menu_options": { - "modbus_tcp": "Modbus TCP", - "serial": "Serial (including serial proxies and networked connections)" - } - } - } - }, - "exceptions": { - "connection_not_ready": { - "message": "Modbus connection not ready" - } - }, - "selector": { - "parity": { - "options": { - "e": "Even", - "n": "None", - "o": "Odd" - } - } - } -} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index da9a9ef06b1b1a..2cfe8887bdc9c5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -478,7 +478,6 @@ "mjpeg", "moat", "mobile_app", - "modbus_connection", "modem_callerid", "modern_forms", "moehlenhoff_alpha2", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b676f78a5c2aa4..eb8c5b96c390c2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4416,12 +4416,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "modbus_connection": { - "name": "Modbus Connection", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "modem_callerid": { "name": "Phone Modem", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 2da3ccca92c703..6752fcf2621c5c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3587,16 +3587,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.modbus_connection.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.modem_callerid.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e7521e0cb174ac..19eddda8eddf86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1600,9 +1600,6 @@ mitsubishi-comfort==0.3.2 # homeassistant.components.moat moat-ble==0.1.1 -# homeassistant.components.modbus_connection -modbus-connection[tmodbus]==3.7.0 - # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 diff --git a/tests/components/modbus_connection/__init__.py b/tests/components/modbus_connection/__init__.py deleted file mode 100644 index ecbad3432af667..00000000000000 --- a/tests/components/modbus_connection/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Modbus Connection integration.""" diff --git a/tests/components/modbus_connection/conftest.py b/tests/components/modbus_connection/conftest.py deleted file mode 100644 index 379fcd664435cb..00000000000000 --- a/tests/components/modbus_connection/conftest.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Common fixtures for the Modbus Connection tests.""" - -from collections.abc import Generator -from unittest.mock import AsyncMock, patch - -from modbus_connection.mock import MockModbusConnection -import pytest - -from homeassistant.components.modbus_connection.const import CONNECTION_TCP, DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Prevent the created entry from actually setting up during flow tests.""" - with patch( - "homeassistant.components.modbus_connection.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def mock_connect( - mock_modbus_connection: MockModbusConnection, -) -> Generator[AsyncMock]: - """Patch the backend connect functions to return the mock connection.""" - connect = AsyncMock(return_value=mock_modbus_connection) - with ( - patch("homeassistant.components.modbus_connection.connect_tcp", connect), - patch("homeassistant.components.modbus_connection.connect_serial", connect), - ): - yield connect - - -@pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: - """Return a TCP connection config entry, already added to hass.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="1.2.3.4:502", - data={CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - ) - entry.add_to_hass(hass) - return entry - - -@pytest.fixture -async def init_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connect: AsyncMock, -) -> MockConfigEntry: - """Set up the connection entry (loaded). - - Relies on ``mock_config_entry`` already being in hass. - """ - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - return mock_config_entry diff --git a/tests/components/modbus_connection/test_config_flow.py b/tests/components/modbus_connection/test_config_flow.py deleted file mode 100644 index cb5d4d199cafe5..00000000000000 --- a/tests/components/modbus_connection/test_config_flow.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Tests for the Modbus Connection config flow.""" - -from typing import Any -from unittest.mock import AsyncMock - -from modbus_connection import ModbusConnectionError -from modbus_connection.mock import MockModbusConnection -import pytest - -from homeassistant.components.modbus_connection.const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - CONNECTION_TCP, - DOMAIN, -) -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - -SERIAL_INPUT = { - CONF_DEVICE: "/dev/ttyUSB0", - CONF_BAUDRATE: 9600, - CONF_PARITY: "n", - CONF_STOPBITS: 1, - CONF_BYTESIZE: 8, -} - - -async def _start_menu(hass: HomeAssistant, step: str) -> str: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.MENU - assert set(result["menu_options"]) == {"modbus_tcp", "serial"} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": step} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == step - return result["flow_id"] - - -@pytest.mark.usefixtures("mock_connect", "mock_setup_entry") -async def test_modbus_tcp_flow(hass: HomeAssistant) -> None: - """The Modbus TCP step opens the connection and creates an entry.""" - flow_id = await _start_menu(hass, "modbus_tcp") - result = await hass.config_entries.flow.async_configure( - flow_id, {CONF_HOST: "1.2.3.4", CONF_PORT: 502} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_TYPE: CONNECTION_TCP, - CONF_HOST: "1.2.3.4", - CONF_PORT: 502, - } - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_modbus_tcp_cannot_connect_then_recovers( - hass: HomeAssistant, - mock_connect: AsyncMock, - mock_modbus_connection: MockModbusConnection, -) -> None: - """A failed probe shows an error; a later success creates the entry.""" - flow_id = await _start_menu(hass, "modbus_tcp") - mock_connect.side_effect = ModbusConnectionError("nope") - result = await hass.config_entries.flow.async_configure( - flow_id, {CONF_HOST: "1.2.3.4", CONF_PORT: 502} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - mock_connect.side_effect = None - mock_connect.return_value = mock_modbus_connection - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_PORT: 502} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - -@pytest.mark.usefixtures("mock_connect", "mock_setup_entry") -async def test_serial_flow(hass: HomeAssistant) -> None: - """The serial step opens the connection and creates a serial entry.""" - flow_id = await _start_menu(hass, "serial") - result = await hass.config_entries.flow.async_configure(flow_id, SERIAL_INPUT) - assert result["type"] is FlowResultType.CREATE_ENTRY - # Parity is stored uppercase (the code the connection expects). - assert result["data"] == { - CONF_TYPE: CONNECTION_SERIAL, - **SERIAL_INPUT, - CONF_PARITY: "N", - } - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_serial_cannot_open(hass: HomeAssistant, mock_connect: AsyncMock) -> None: - """A failed serial open shows the serial-specific error.""" - flow_id = await _start_menu(hass, "serial") - mock_connect.side_effect = ModbusConnectionError("nope") - result = await hass.config_entries.flow.async_configure(flow_id, SERIAL_INPUT) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_open_serial_port"} - - -@pytest.mark.parametrize( - ("step", "data", "user_input"), - [ - pytest.param( - "modbus_tcp", - {CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - {CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - id="modbus_tcp", - ), - pytest.param( - "serial", - {CONF_TYPE: CONNECTION_SERIAL, **SERIAL_INPUT}, - SERIAL_INPUT, - id="serial", - ), - ], -) -async def test_duplicate_aborts( - hass: HomeAssistant, - step: str, - data: dict[str, Any], - user_input: dict[str, Any], -) -> None: - """Re-adding an already-configured link aborts before opening it. - - The dedupe runs before opening the connection, so no connect is needed. - """ - MockConfigEntry(domain=DOMAIN, data=data).add_to_hass(hass) - - flow_id = await _start_menu(hass, step) - result = await hass.config_entries.flow.async_configure(flow_id, user_input) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/modbus_connection/test_init.py b/tests/components/modbus_connection/test_init.py deleted file mode 100644 index 9b49de25ef3904..00000000000000 --- a/tests/components/modbus_connection/test_init.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Tests for Modbus Connection setup, teardown and the async_get_unit accessor.""" - -from typing import Any -from unittest.mock import AsyncMock, patch - -from modbus_connection import ModbusConnectionError, ModbusError -from modbus_connection.mock import MockModbusConnection, MockModbusUnit -import pytest - -from homeassistant.components.modbus_connection import ( - ConnectionNotReady, - async_get_unit, -) -from homeassistant.components.modbus_connection.const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - CONNECTION_TCP, - DOMAIN, -) -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_setup_and_unload( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_modbus_connection: MockModbusConnection, -) -> None: - """A connection entry loads, exposes runtime data, and closes on unload.""" - assert init_integration.state is ConfigEntryState.LOADED - assert init_integration.runtime_data is mock_modbus_connection - assert mock_modbus_connection.connected is True - - assert await hass.config_entries.async_unload(init_integration.entry_id) - await hass.async_block_till_done() - assert init_integration.state is ConfigEntryState.NOT_LOADED - assert mock_modbus_connection.connected is False - - -@pytest.mark.parametrize( - ("data", "error"), - [ - pytest.param( - {CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - ModbusConnectionError("boom"), - id="tcp", - ), - pytest.param( - { - CONF_TYPE: CONNECTION_SERIAL, - CONF_DEVICE: "/dev/ttyUSB0", - CONF_BAUDRATE: 9600, - CONF_PARITY: "N", - CONF_STOPBITS: 1, - CONF_BYTESIZE: 8, - }, - ModbusError("port busy"), - id="serial", - ), - ], -) -async def test_setup_retry_when_connect_fails( - hass: HomeAssistant, - mock_connect: AsyncMock, - data: dict[str, Any], - error: ModbusError, -) -> None: - """A failed open raises ConfigEntryNotReady (setup retry). - - The serial case uses a generic ``ModbusError`` (not a ``ModbusConnectionError``) - to confirm setup retries on any library error, matching the config flow. - """ - entry = MockConfigEntry(domain=DOMAIN, data=data) - entry.add_to_hass(hass) - mock_connect.side_effect = error - - assert not await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_connection_lost_schedules_reload( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_modbus_connection: MockModbusConnection, -) -> None: - """Losing the connection schedules a reload of the entry.""" - with patch.object(hass.config_entries, "async_schedule_reload") as schedule_reload: - mock_modbus_connection.simulate_connection_lost() - await hass.async_block_till_done() - - schedule_reload.assert_called_once_with(init_integration.entry_id) - - -async def test_get_unit_returns_connection_unit( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_modbus_unit: MockModbusUnit, -) -> None: - """async_get_unit hands back the connection's own unit handle.""" - assert async_get_unit(hass, init_integration.entry_id, 1) is mock_modbus_unit - - -async def test_get_unit_not_ready_when_unloaded( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """A modbus_connection entry that is not loaded raises ConnectionNotReady.""" - # mock_config_entry is added to hass but never set up -> not LOADED. - with pytest.raises(ConnectionNotReady): - async_get_unit(hass, mock_config_entry.entry_id, 1) - - -async def test_get_unit_rejects_invalid_entry(hass: HomeAssistant) -> None: - """An unknown entry_id or a foreign-domain entry raises ValueError.""" - with pytest.raises(ValueError): - async_get_unit(hass, "does-not-exist", 1) - - other = MockConfigEntry(domain="sun", state=ConfigEntryState.LOADED) - other.add_to_hass(hass) - with pytest.raises(ValueError): - async_get_unit(hass, other.entry_id, 1) From ac6aedab34ca64835fbf70925e7a203fd4c9ca5e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jul 2026 16:55:02 +0200 Subject: [PATCH 11/13] Create individual devices for telegram_bot chats (#176606) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/telegram_bot/__init__.py | 65 ++++++ .../components/telegram_bot/config_flow.py | 2 +- .../components/telegram_bot/entity.py | 16 +- .../components/telegram_bot/notify.py | 10 +- tests/components/telegram_bot/test_init.py | 203 +++++++++++++++++- tests/components/telegram_bot/test_notify.py | 4 +- .../telegram_bot/test_telegram_bot.py | 28 +-- 7 files changed, 291 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 18c979b2f373ef..247c057d030aa0 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -3,6 +3,7 @@ import logging from typing import Protocol, cast +import telegram from telegram import Bot from telegram.constants import InputMediaType from telegram.error import InvalidToken, TelegramError @@ -33,6 +34,7 @@ ) from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, entity_registry as er, issue_registry as ir, ) @@ -104,6 +106,7 @@ CHAT_ACTION_UPLOAD_VIDEO_NOTE, CHAT_ACTION_UPLOAD_VOICE, CONF_API_ENDPOINT, + CONF_CHAT_ID, CONF_CONFIG_ENTRY_ID, DEFAULT_API_ENDPOINT, DOMAIN, @@ -705,6 +708,50 @@ async def async_migrate_entry( updated, ) + # version 1.2 -> 1.3: move each chat's notify entity onto its own per-chat device + # (linked to the bot device) and strip the chat subentries from the bot device, leaving + # it associated with only (entry, None). + if version == 1 and config_entry.minor_version < 3: + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + # Up to 1.2 the entry has a single device, the bot device, shared by every chat + devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + if devices: + bot_device = devices[0] + bot_id = next( + identifier + for domain, identifier in bot_device.identifiers + if domain == DOMAIN + ) + notify_entities = { + entity.config_subentry_id: entity + for entity in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + # The event entity (no subentry) stays on the shared bot device + if entity.config_subentry_id is not None + } + for subentry_id, subentry in config_entry.subentries.items(): + per_chat_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=subentry_id, + identifiers={(DOMAIN, f"{bot_id}_{subentry.data[CONF_CHAT_ID]}")}, + via_device=(DOMAIN, bot_id), + ) + if entity := notify_entities.get(subentry_id): + entity_registry.async_update_entity( + entity.entity_id, device_id=per_chat_device.id + ) + # Strip this chat's subentry from the bot device, leaving (entry, None) + device_registry.async_update_device( + bot_device.id, + remove_config_entry_id=config_entry.entry_id, + remove_config_subentry_id=subentry_id, + ) + hass.config_entries.async_update_entry(config_entry, minor_version=3) + return True @@ -906,6 +953,18 @@ def _warn_chat_id_migration(service: ServiceCall) -> set[int]: return chat_ids +def bot_device_info(config_entry: TelegramBotConfigEntry, bot_id: int) -> dr.DeviceInfo: + """Return device info for the shared bot device.""" + return dr.DeviceInfo( + name=config_entry.title, + entry_type=dr.DeviceEntryType.SERVICE, + manufacturer="Telegram", + model=config_entry.data[CONF_PLATFORM].capitalize(), + sw_version=telegram.__version__, + identifiers={(DOMAIN, f"{bot_id}")}, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> bool: """Create the Telegram bot from config entry.""" bot: Bot = await hass.async_add_executor_job(initialize_bot, hass, entry.data) @@ -933,6 +992,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) ) entry.runtime_data = notify_service + # Create the bot device before the platforms are set up, so the per-chat devices can + # resolve it as their via_device no matter which platform is set up first + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **bot_device_info(entry, bot.id) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 6d5422b8368ba4..0aa84bc996c95e 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -192,7 +192,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Telegram.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 @staticmethod @callback diff --git a/homeassistant/components/telegram_bot/entity.py b/homeassistant/components/telegram_bot/entity.py index 95adc934781aec..1b71426a89fe0b 100644 --- a/homeassistant/components/telegram_bot/entity.py +++ b/homeassistant/components/telegram_bot/entity.py @@ -1,13 +1,8 @@ """Base entity for Telegram bot integration.""" -import telegram - -from homeassistant.const import CONF_PLATFORM -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from . import TelegramBotConfigEntry -from .const import DOMAIN +from . import TelegramBotConfigEntry, bot_device_info class TelegramBotEntity(Entity): @@ -28,11 +23,4 @@ def __init__( self.service = config_entry.runtime_data self._attr_unique_id = f"{self.bot_id}_{entity_description.key}" - self._attr_device_info = DeviceInfo( - name=config_entry.title, - entry_type=DeviceEntryType.SERVICE, - manufacturer="Telegram", - model=config_entry.data[CONF_PLATFORM].capitalize(), - sw_version=telegram.__version__, - identifiers={(DOMAIN, f"{self.bot_id}")}, - ) + self._attr_device_info = bot_device_info(config_entry, self.bot_id) diff --git a/homeassistant/components/telegram_bot/notify.py b/homeassistant/components/telegram_bot/notify.py index c49d106a84d285..e95cf2de681e3c 100644 --- a/homeassistant/components/telegram_bot/notify.py +++ b/homeassistant/components/telegram_bot/notify.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TelegramBotConfigEntry -from .const import ATTR_TITLE, CONF_CHAT_ID +from .const import ATTR_TITLE, CONF_CHAT_ID, DOMAIN from .entity import TelegramBotEntity @@ -33,6 +33,7 @@ async def async_setup_entry( class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity): """Representation of a telegram bot notification entity.""" + _attr_name = None _attr_supported_features = NotifyEntityFeature.TITLE def __init__( @@ -45,7 +46,12 @@ def __init__( config_entry, NotifyEntityDescription(key=subentry.data[CONF_CHAT_ID]) ) self.chat_id = subentry.data[CONF_CHAT_ID] - self._attr_name = subentry.title + # Each chat gets its own device (keyed per chat) linked to the shared bot device. + device_info = self._attr_device_info + assert device_info is not None + device_info["identifiers"] = {(DOMAIN, f"{self.bot_id}_{self.chat_id}")} + device_info["name"] = subentry.title + device_info["via_device"] = (DOMAIN, f"{self.bot_id}") @override async def async_send_message(self, message: str, title: str | None = None) -> None: diff --git a/tests/components/telegram_bot/test_init.py b/tests/components/telegram_bot/test_init.py index f5d54e19b24106..7c3bfb6cacf016 100644 --- a/tests/components/telegram_bot/test_init.py +++ b/tests/components/telegram_bot/test_init.py @@ -1,16 +1,21 @@ """Init tests for the Telegram Bot integration.""" +import pytest + from homeassistant.components.telegram_bot.const import ( ATTR_PARSER, + CONF_ALLOWED_CHAT_IDS, CONF_API_ENDPOINT, + CONF_CHAT_ID, DEFAULT_API_ENDPOINT, DOMAIN, PARSER_MD, PLATFORM_BROADCAST, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData from homeassistant.const import CONF_API_KEY, CONF_PLATFORM from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -19,7 +24,7 @@ async def test_migration_error( hass: HomeAssistant, mock_external_calls: None, ) -> None: - """Test migrate config entry from 1.1 to 1.2.""" + """Test migrate config entry from unsupported version.""" mock_config_entry = MockConfigEntry( unique_id="mock api key", @@ -43,7 +48,7 @@ async def test_migrate_entry_from_1_1( hass: HomeAssistant, mock_external_calls: None, ) -> None: - """Test migrate config entry from 1.1 to 1.2.""" + """Test migrate config entry from 1.1, chaining through to the latest version.""" mock_config_entry = MockConfigEntry( unique_id="mock api key", @@ -61,9 +66,199 @@ async def test_migrate_entry_from_1_1( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.version == 1 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", CONF_API_ENDPOINT: DEFAULT_API_ENDPOINT, } + + +@pytest.mark.parametrize( + "chats_without_notify_entity", + [ + pytest.param((), id="notify entities intact"), + pytest.param((654321,), id="notify entity deleted"), + ], +) +async def test_migrate_entry_to_per_chat_devices( + hass: HomeAssistant, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + chats_without_notify_entity: tuple[int, ...], +) -> None: + """Test migrating a shared bot device to per-chat devices.""" + bot_id = 123456 # test_user id from mock_external_calls + chat_ids = (123456, 654321) + config_entry = MockConfigEntry( + unique_id="mock api key", + domain=DOMAIN, + minor_version=2, + data={ + CONF_PLATFORM: PLATFORM_BROADCAST, + CONF_API_KEY: "mock api key", + CONF_API_ENDPOINT: DEFAULT_API_ENDPOINT, + }, + options={ATTR_PARSER: PARSER_MD}, + subentries_data=[ + ConfigSubentryData( + unique_id="123456", + data={CONF_CHAT_ID: 123456}, + subentry_type=CONF_ALLOWED_CHAT_IDS, + title="chat 1", + ), + ConfigSubentryData( + unique_id="654321", + data={CONF_CHAT_ID: 654321}, + subentry_type=CONF_ALLOWED_CHAT_IDS, + title="chat 2", + ), + ], + ) + config_entry.add_to_hass(hass) + subentry_ids = list(config_entry.subentries) + + # Pre-migration state: one shared bot device associated with the config entry (None) + # and every chat subentry, holding the event entity and every chat's notify entity. + bot_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, str(bot_id))}, + ) + for subentry_id in subentry_ids: + bot_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=subentry_id, + identifiers={(DOMAIN, str(bot_id))}, + ) + assert bot_device.config_entries_subentries == { + config_entry.entry_id: {None, *subentry_ids} + } + + event_entity = entity_registry.async_get_or_create( + "event", + DOMAIN, + f"{bot_id}_update_event", + config_entry=config_entry, + device_id=bot_device.id, + ) + notify_entities = { + chat_id: entity_registry.async_get_or_create( + "notify", + DOMAIN, + f"{bot_id}_{chat_id}", + config_entry=config_entry, + config_subentry_id=subentry_id, + device_id=bot_device.id, + ) + for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True) + if chat_id not in chats_without_notify_entity + } + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.minor_version == 3 + + # Each chat has its own device, owned by that chat's subentry and linked to the bot + # device. + chat_devices = { + chat_id: device_registry.async_get_device( + identifiers={(DOMAIN, f"{bot_id}_{chat_id}")} + ) + for chat_id in chat_ids + } + for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True): + chat_device = chat_devices[chat_id] + assert chat_device is not None + assert chat_device.config_entries_subentries == { + config_entry.entry_id: {subentry_id} + } + assert chat_device.via_device_id == bot_device.id + + # Every notify entity that survived is moved onto its chat's device + for chat_id, notify_entity in notify_entities.items(): + assert ( + entity_registry.async_get(notify_entity.entity_id).device_id + == chat_devices[chat_id].id + ) + + # The bot device ends up associated with only (entry, None), keeping the event entity + bot_device = device_registry.async_get(bot_device.id) + assert bot_device is not None + assert bot_device.config_entries_subentries == {config_entry.entry_id: {None}} + assert entity_registry.async_get(event_entity.entity_id).device_id == bot_device.id + + +async def test_per_chat_devices( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Each chat gets its own device linked to the config-entry-level bot device.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + entry_id = mock_broadcast_config_entry.entry_id + + # The bot device belongs to the config entry (no subentry) and holds the event entity + bot_device = device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) + assert bot_device is not None + assert bot_device.config_entries_subentries == {entry_id: {None}} + assert bot_device.name == "Mock Title" + + for chat_id, chat_name in ((123456, "mock chat 1"), (654321, "mock chat 2")): + subentry_id = next( + sid + for sid, subentry in mock_broadcast_config_entry.subentries.items() + if subentry.data[CONF_CHAT_ID] == chat_id + ) + chat_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"123456_{chat_id}")} + ) + assert chat_device is not None + assert chat_device.config_entries_subentries == {entry_id: {subentry_id}} + assert chat_device.via_device_id == bot_device.id + # The device is named after the chat, and its notify entity takes the device name + assert chat_device.name == chat_name + notify_entity_id = entity_registry.async_get_entity_id( + "notify", DOMAIN, f"123456_{chat_id}" + ) + assert notify_entity_id is not None + assert entity_registry.async_get(notify_entity_id).device_id == chat_device.id + assert hass.states.get(notify_entity_id).name == chat_name + + +async def test_remove_chat_subentry_removes_per_chat_device( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Removing a chat subentry removes just its per-chat device and notify entity.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + subentry_id = next( + sid + for sid, subentry in mock_broadcast_config_entry.subentries.items() + if subentry.data[CONF_CHAT_ID] == 123456 + ) + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456_123456")}) + assert entity_registry.async_get_entity_id("notify", DOMAIN, "123456_123456") + + hass.config_entries.async_remove_subentry(mock_broadcast_config_entry, subentry_id) + await hass.async_block_till_done() + + # The removed chat's device and notify entity are gone; the other chat and the bot + # device remain + assert not device_registry.async_get_device(identifiers={(DOMAIN, "123456_123456")}) + assert not entity_registry.async_get_entity_id("notify", DOMAIN, "123456_123456") + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456_654321")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) diff --git a/tests/components/telegram_bot/test_notify.py b/tests/components/telegram_bot/test_notify.py index 2305114127d94a..ffa4daebff121d 100644 --- a/tests/components/telegram_bot/test_notify.py +++ b/tests/components/telegram_bot/test_notify.py @@ -43,7 +43,7 @@ async def test_send_message( NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, { - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", ATTR_MESSAGE: "mock message", ATTR_TITLE: "mock title", }, @@ -64,7 +64,7 @@ async def test_send_message( message_thread_id=None, ) - state = hass.states.get("notify.mock_title_mock_chat") + state = hass.states.get("notify.mock_chat") assert state assert state.state == "2025-01-09T12:00:00+00:00" diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index a4592826d752fe..3e7fd284850841 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -224,7 +224,7 @@ async def test_send_message( { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -322,7 +322,7 @@ async def test_send_message_with_inline_keyboard( { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -368,9 +368,9 @@ async def test_send_sticker_partial_error( assert err.value.translation_key == "multiple_errors" assert err.value.translation_placeholders == { "errors": ( - "`entity_id` notify.mock_title_mock_chat_1:" + "`entity_id` notify.mock_chat_1:" " mock network error\n" - "`entity_id` notify.mock_title_mock_chat_2:" + "`entity_id` notify.mock_chat_2:" " mock network error" ) } @@ -588,7 +588,7 @@ async def test_send_file(hass: HomeAssistant, webhook_bot, service: str) -> None { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -1076,7 +1076,7 @@ async def test_send_message_with_config_entry( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1187,7 +1187,7 @@ async def test_delete_message( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1616,7 +1616,7 @@ async def test_send_video( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1648,7 +1648,7 @@ async def test_send_video( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1837,7 +1837,7 @@ async def test_send_message_multi_target( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -1857,7 +1857,7 @@ async def test_notify_entity_send_message( response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, - {ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", ATTR_MESSAGE: "test_message"}, + {ATTR_ENTITY_ID: "notify.mock_chat_2", ATTR_MESSAGE: "test_message"}, blocking=True, return_response=True, ) @@ -1867,7 +1867,7 @@ async def test_notify_entity_send_message( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -1921,7 +1921,7 @@ async def test_migrate_chat_id( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -2616,7 +2616,7 @@ async def test_send_media_group( "chats": [ { ATTR_CHAT_ID: 123456, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", ATTR_MESSAGE_ID: [12345, 12346, 12347, 12348], } ] From ec366c5b7f02e884d7f0daf7c8ec25fc62377406 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 16 Jul 2026 17:03:37 +0200 Subject: [PATCH 12/13] Rename Modbus integration back to Modbus (#176619) Co-authored-by: Claude Fable 5 --- homeassistant/components/modbus/manifest.json | 2 +- homeassistant/components/modbus/strings.json | 3 +-- homeassistant/generated/integrations.json | 2 +- script/hassfest/translations.py | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 7709841a3b9673..30945c8a13dfe3 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,6 +1,6 @@ { "domain": "modbus", - "name": "Manual Modbus", + "name": "Modbus", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 08d29cc9aafb93..d0d78d726e05ef 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -90,6 +90,5 @@ }, "name": "Write register" } - }, - "title": "Manual Modbus" + } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index eb8c5b96c390c2..1126febd6237b1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4412,6 +4412,7 @@ "iot_class": "local_polling" }, "modbus": { + "name": "Modbus", "integration_type": "hub", "config_flow": false, "iot_class": "local_polling" @@ -8548,7 +8549,6 @@ "local_todo", "min_max", "mobile_app", - "modbus", "moehlenhoff_alpha2", "mold_indicator", "moon", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 7369ed7b731062..f9f2d1df308da8 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -45,7 +45,6 @@ "local_calendar", "local_ip", "local_todo", - "modbus", "nmap_tracker", "remote_calendar", "rpi_power", From 2d0ac67a9ab11e4efb06e1d80caf4456372d2114 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:04:08 +0100 Subject: [PATCH 13/13] Update uv to 0.11.28 (#176585) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd2b9ffc2b6b17..9d1303105b2cda 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.26 +uv==0.11.28 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 7d545f071e6ddb..76c8700a0ec9a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.11.26", + "uv==0.11.28", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", diff --git a/requirements.txt b/requirements.txt index 79d031f9c272f8..ae925cb0090211 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.26 +uv==0.11.28 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2